// Port of web/kit/Formatters.ts.
package webui

import (
	"fmt"
	"math"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// formattersNonDigit matches every non-digit rune (the JS /\D/g).
var formattersNonDigit = regexp.MustCompile(`\D`)

// formattersDigits strips every non-digit character (mirrors String(n).replace(/\D/g, "")).
func formattersDigits(s string) string {
	return formattersNonDigit.ReplaceAllString(s, "")
}

// formattersStringify mimics JavaScript's String(v) for the `string | number`
// union accepted by several formatters. Go has no union type, so these take
// `any` and stringify int / float / string inputs the way JS would.
func formattersStringify(v any) string {
	switch n := v.(type) {
	case string:
		return n
	case int:
		return strconv.Itoa(n)
	case int8, int16, int32, int64:
		return fmt.Sprintf("%d", n)
	case uint, uint8, uint16, uint32, uint64:
		return fmt.Sprintf("%d", n)
	case float32:
		return strconv.FormatFloat(float64(n), 'f', -1, 32)
	case float64:
		return strconv.FormatFloat(n, 'f', -1, 64)
	default:
		return fmt.Sprintf("%v", v)
	}
}

// formattersPadStartZero left-pads s with '0' up to length (the JS padStart(n, "0")).
func formattersPadStartZero(s string, length int) string {
	if len(s) >= length {
		return s
	}
	return strings.Repeat("0", length-len(s)) + s
}

// formattersParseInt approximates JS parseInt(s, 10): optional sign then leading
// decimal digits. JS would yield NaN for non-numeric input; here we return 0.
func formattersParseInt(s string) int {
	s = strings.TrimSpace(s)
	i := 0
	neg := false
	if i < len(s) && (s[i] == '+' || s[i] == '-') {
		neg = s[i] == '-'
		i++
	}
	start := i
	for i < len(s) && s[i] >= '0' && s[i] <= '9' {
		i++
	}
	if start == i {
		return 0
	}
	n, err := strconv.Atoi(s[start:i])
	if err != nil {
		return 0
	}
	if neg {
		return -n
	}
	return n
}

// FormatPhoneNumber formats a number as a US phone number: (XXX) XXX-XXXX.
func FormatPhoneNumber(number any) string {
	digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 10)[:10]
	areaCode := digits[0:3]
	centralOfficeCode := digits[3:6]
	lineNumber := digits[6:10]
	return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber
}

// FormatZipCode formats a number as a US zip code (5 or 9 digits).
func FormatZipCode(number any) string {
	num := formattersParseInt(formattersStringify(number))

	if num <= 99999 {
		return formattersPadStartZero(strconv.Itoa(num), 5)
	}

	digits := formattersPadStartZero(strconv.Itoa(num), 9)
	zipCode := digits[0:5]
	plus4 := digits[5:9]
	return zipCode + "-" + plus4
}

// FormatTaxID formats a number as a US Tax ID (EIN): XX-XXXXXXX.
func FormatTaxID(number any) string {
	digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 9)[:9]
	prefix := digits[0:2]
	identifier := digits[2:9]
	return prefix + "-" + identifier
}

// formattersGroupThousands inserts ',' every three digits from the right of an
// integer-digit string (no sign, no fraction).
func formattersGroupThousands(intDigits string) string {
	n := len(intDigits)
	if n <= 3 {
		return intDigits
	}
	var b strings.Builder
	pre := n % 3
	if pre > 0 {
		b.WriteString(intDigits[:pre])
	}
	for i := pre; i < n; i += 3 {
		if b.Len() > 0 {
			b.WriteByte(',')
		}
		b.WriteString(intDigits[i : i+3])
	}
	return b.String()
}

// formattersFormatGrouped renders n like Intl.NumberFormat("en-US") with the
// given minimum/maximum fraction digits: rounds to maxFrac, trims trailing zeros
// down to minFrac, and groups the integer part with commas.
func formattersFormatGrouped(n float64, minFrac, maxFrac int) string {
	neg := math.Signbit(n)
	s := strconv.FormatFloat(math.Abs(n), 'f', maxFrac, 64)

	intPart, fracPart := s, ""
	if dot := strings.IndexByte(s, '.'); dot >= 0 {
		intPart = s[:dot]
		fracPart = s[dot+1:]
	}

	for len(fracPart) > minFrac && strings.HasSuffix(fracPart, "0") {
		fracPart = fracPart[:len(fracPart)-1]
	}

	intPart = formattersGroupThousands(intPart)
	res := intPart
	if len(fracPart) > 0 {
		res += "." + fracPart
	}

	// Only keep the sign when the rounded result is actually non-zero (avoids "-0").
	if neg && strings.ContainsFunc(res, func(r rune) bool { return r >= '1' && r <= '9' }) {
		res = "-" + res
	}
	return res
}

// FormatNumber formats a number with US thousands separators (Intl.NumberFormat
// "en-US" defaults: 0 minimum and 3 maximum fraction digits).
func FormatNumber(number float64) string {
	return formattersFormatGrouped(number, 0, 3)
}

// FormatDecimal formats a number with US thousands separators and a fixed number
// of fraction digits (default 2). Go has no default parameters, so decimalPlaces
// is an optional variadic argument.
func FormatDecimal(number float64, decimalPlaces ...int) string {
	dp := 2
	if len(decimalPlaces) > 0 {
		dp = decimalPlaces[0]
	}
	return formattersFormatGrouped(number, dp, dp)
}

// State Code Utilities

var formattersStateCodeMap = map[string]string{
	"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA",
	"Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL",
	"Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN",
	"Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME",
	"Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
	"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH",
	"New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND",
	"Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR",
	"Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
	"Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA",
	"West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY",
}

// StateToStateCode resolves a state name to its two-letter code, matching the
// exact name first and then case-insensitively. Returns "" when unknown.
func StateToStateCode(state string) string {
	if code, ok := formattersStateCodeMap[state]; ok {
		return code
	}

	stateLower := strings.ToLower(state)
	for stateName, code := range formattersStateCodeMap {
		if strings.ToLower(stateName) == stateLower {
			return code
		}
	}

	return ""
}

// StateCodeToState resolves a two-letter code to its state name. Returns "" when unknown.
func StateCodeToState(code string) string {
	up := strings.ToUpper(code)
	for state, stateCode := range formattersStateCodeMap {
		if stateCode == up {
			return state
		}
	}
	return ""
}

// IsValidStateCode reports whether code is a known two-letter state code.
func IsValidStateCode(code string) bool {
	up := strings.ToUpper(code)
	for _, stateCode := range formattersStateCodeMap {
		if stateCode == up {
			return true
		}
	}
	return false
}

// formattersDateLayouts are the layouts tried when coercing a string into a
// time.Time, standing in for JS `new Date(string)` (which cannot be reproduced
// exactly). Ordered most-specific first.
var formattersDateLayouts = []string{
	time.RFC3339Nano,
	time.RFC3339,
	"2006-01-02T15:04:05",
	"2006-01-02T15:04",
	"2006-01-02 15:04:05",
	"2006-01-02",
	"2006/01/02",
	"01/02/2006",
	time.RFC1123Z,
	time.RFC1123,
	time.ANSIC,
}

// formattersToTime coerces the `string | Date` union into a time.Time. The bool
// is false when a string could not be parsed (JS would produce "Invalid Date").
func formattersToTime(date any) (time.Time, bool) {
	switch d := date.(type) {
	case time.Time:
		return d, true
	case string:
		for _, layout := range formattersDateLayouts {
			if t, err := time.Parse(layout, d); err == nil {
				return t, true
			}
		}
		return time.Time{}, false
	default:
		return time.Time{}, false
	}
}

// FormatDate formats a date as MM/DD/YYYY. Returns "" for an unparseable string.
func FormatDate(date any) string {
	d, ok := formattersToTime(date)
	if !ok {
		return ""
	}
	return d.Format("01/02/2006")
}

// FormatDateLong formats a date as e.g. "January 2, 2006". Returns "" for an
// unparseable string.
func FormatDateLong(date any) string {
	d, ok := formattersToTime(date)
	if !ok {
		return ""
	}
	return d.Format("January 2, 2006")
}

// FormatDateTime formats a date+time as e.g. "01/02/2006, 3:04 PM". Returns ""
// for an unparseable string.
func FormatDateTime(date any) string {
	d, ok := formattersToTime(date)
	if !ok {
		return ""
	}
	return d.Format("01/02/2006, 3:04 PM")
}

// FormatPercent formats a value as a percentage with a fixed number of decimal
// places. When isDecimal is true the value is treated as a fraction (0-1) and
// scaled by 100. Go has no default parameters, so decimalPlaces and isDecimal
// (defaults 2 and false in the TS source) are required here.
func FormatPercent(value float64, decimalPlaces int, isDecimal bool) string {
	percent := value
	if isDecimal {
		percent = value * 100
	}
	return strconv.FormatFloat(percent, 'f', decimalPlaces, 64) + "%"
}
