package main

// color.go is the colour engine: it parses every colour syntax Tailwind can emit
// into one target space — gamma-encoded sRGB with an alpha channel — and from there
// computes the WCAG 2.x relative luminance and contrast ratio exactly as WebAIM's
// checker does (https://webaim.org/resources/contrastchecker/).
//
// sRGB is the target space on purpose. WCAG defines luminance in terms of sRGB, so
// converting there once means the contrast maths is a single well-specified formula
// and never depends on which syntax a colour was written in. The palette is authored
// in OKLCH, the semantic tokens in hex, and an app can drop an oklab()/rgb()/hsl()
// literal into an arbitrary value — all of them land here as an RGBA before any
// contrast is computed.

import (
	"math"
	"strconv"
	"strings"
)

// RGBA is a colour in gamma-encoded sRGB. Channels and alpha are all in [0,1].
// This is aria-check's single internal colour representation — the "target
// colorspace" every parser converts into.
type RGBA struct {
	R, G, B, A float64
}

// Opaque reports whether the colour needs no compositing.
func (c RGBA) Opaque() bool { return c.A >= 1 }

// over composites c (the source) onto an opaque backdrop using the standard
// source-over rule, in gamma space. WCAG contrast is only defined for opaque
// colours, so a translucent foreground or a translucent surface must be flattened
// against what sits behind it before its luminance means anything. Compositing in
// gamma-encoded sRGB (rather than linear) is the approximation browsers and the
// WebAIM checker effectively use.
func (c RGBA) over(bg RGBA) RGBA {
	if c.Opaque() {
		return c
	}
	a := c.A
	return RGBA{
		R: c.R*a + bg.R*(1-a),
		G: c.G*a + bg.G*(1-a),
		B: c.B*a + bg.B*(1-a),
		A: 1,
	}
}

// luminance is the WCAG relative luminance of an (assumed opaque) colour: linearise
// each sRGB channel, then weight. This is byte-for-byte the WebAIM formula, including
// its 0.03928 threshold.
func (c RGBA) luminance() float64 {
	lin := func(ch float64) float64 {
		if ch <= 0.03928 {
			return ch / 12.92
		}
		return math.Pow((ch+0.055)/1.055, 2.4)
	}
	return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B)
}

// contrastRatio returns the WCAG contrast ratio between two opaque colours, in
// [1, 21]. Order does not matter. Callers must composite any translucency away first
// (see over) — this treats both colours as fully opaque.
func contrastRatio(a, b RGBA) float64 {
	la, lb := a.luminance(), b.luminance()
	if la < lb {
		la, lb = lb, la
	}
	return (la + 0.05) / (lb + 0.05)
}

func clamp01(v float64) float64 {
	if v < 0 {
		return 0
	}
	if v > 1 {
		return 1
	}
	return v
}

// parseLiteralColor parses a self-contained colour literal — one that names no CSS
// variable and is not a color-mix() (those are resolved in theme.go, which has the
// variable environment). It returns ok=false for anything it cannot turn into a
// concrete colour, including the deliberately-unresolvable keywords `currentcolor`,
// `inherit`, `transparent` (transparent is a real colour but alpha 0, handled here).
func parseLiteralColor(s string) (RGBA, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return RGBA{}, false
	}
	lower := strings.ToLower(s)

	switch {
	case strings.HasPrefix(s, "#"):
		return parseHex(s)
	case strings.HasPrefix(lower, "rgb"):
		return parseRGBFunc(s)
	case strings.HasPrefix(lower, "hsl"):
		return parseHSLFunc(s)
	case strings.HasPrefix(lower, "oklch("):
		return parseOKLCH(s)
	case strings.HasPrefix(lower, "oklab("):
		return parseOKLab(s)
	}
	if c, ok := namedColors[lower]; ok {
		return c, true
	}
	return RGBA{}, false
}

func parseHex(s string) (RGBA, bool) {
	h := strings.TrimPrefix(s, "#")
	// Expand shorthand #rgb / #rgba to full byte pairs.
	switch len(h) {
	case 3, 4:
		var sb strings.Builder
		for _, r := range h {
			sb.WriteRune(r)
			sb.WriteRune(r)
		}
		h = sb.String()
	case 6, 8:
	default:
		return RGBA{}, false
	}
	val, err := strconv.ParseUint(h, 16, 64)
	if err != nil {
		return RGBA{}, false
	}
	c := RGBA{A: 1}
	if len(h) == 8 {
		c.R = float64((val>>24)&0xff) / 255
		c.G = float64((val>>16)&0xff) / 255
		c.B = float64((val>>8)&0xff) / 255
		c.A = float64(val&0xff) / 255
	} else {
		c.R = float64((val>>16)&0xff) / 255
		c.G = float64((val>>8)&0xff) / 255
		c.B = float64(val&0xff) / 255
	}
	return c, true
}

// funcArgs splits the inside of a colour function into its space/comma-separated
// components and an optional trailing alpha introduced by `/`. Both the legacy
// comma syntax and the modern space syntax are accepted.
func funcArgs(s string) (parts []string, alpha string) {
	open := strings.IndexByte(s, '(')
	close := strings.LastIndexByte(s, ')')
	if open < 0 || close < 0 || close < open {
		return nil, ""
	}
	body := s[open+1 : close]
	body = strings.ReplaceAll(body, ",", " ")
	if i := strings.IndexByte(body, '/'); i >= 0 {
		alpha = strings.TrimSpace(body[i+1:])
		body = body[:i]
	}
	return strings.Fields(body), alpha
}

// numOrPct parses a number that may be a percentage. A percentage is scaled by
// pctBase (255 for rgb channels, 1 for alpha, 0.4 for oklab/oklch a/b/chroma).
func numOrPct(s string, pctBase float64) (float64, bool) {
	s = strings.TrimSpace(s)
	if s == "" || s == "none" {
		return 0, true
	}
	if pct, ok := strings.CutSuffix(s, "%"); ok {
		v, err := strconv.ParseFloat(pct, 64)
		if err != nil {
			return 0, false
		}
		return v / 100 * pctBase, true
	}
	v, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return 0, false
	}
	return v, true
}

func parseAlpha(s string) float64 {
	if s == "" {
		return 1
	}
	if v, ok := numOrPct(s, 1); ok {
		return clamp01(v)
	}
	return 1
}

func parseRGBFunc(s string) (RGBA, bool) {
	parts, alpha := funcArgs(s)
	if len(parts) < 3 {
		return RGBA{}, false
	}
	r, ok1 := numOrPct(parts[0], 255)
	g, ok2 := numOrPct(parts[1], 255)
	b, ok3 := numOrPct(parts[2], 255)
	if !ok1 || !ok2 || !ok3 {
		return RGBA{}, false
	}
	a := 1.0
	if len(parts) >= 4 {
		a = parseAlpha(parts[3])
	} else if alpha != "" {
		a = parseAlpha(alpha)
	}
	return RGBA{clamp01(r / 255), clamp01(g / 255), clamp01(b / 255), a}, true
}

func parseHSLFunc(s string) (RGBA, bool) {
	parts, alpha := funcArgs(s)
	if len(parts) < 3 {
		return RGBA{}, false
	}
	h, ok1 := parseAngle(parts[0])
	sat, ok2 := numOrPct(parts[1], 1) // percentage → [0,1]
	l, ok3 := numOrPct(parts[2], 1)
	if !ok1 || !ok2 || !ok3 {
		return RGBA{}, false
	}
	a := 1.0
	if len(parts) >= 4 {
		a = parseAlpha(parts[3])
	} else if alpha != "" {
		a = parseAlpha(alpha)
	}
	r, g, b := hslToRGB(h, clamp01(sat), clamp01(l))
	return RGBA{r, g, b, a}, true
}

func parseAngle(s string) (float64, bool) {
	s = strings.TrimSpace(strings.ToLower(s))
	s = strings.TrimSuffix(s, "deg")
	if s == "none" {
		return 0, true
	}
	v, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return 0, false
	}
	return v, true
}

func hslToRGB(h, s, l float64) (float64, float64, float64) {
	h = math.Mod(math.Mod(h, 360)+360, 360) / 360
	if s == 0 {
		return l, l, l
	}
	var q float64
	if l < 0.5 {
		q = l * (1 + s)
	} else {
		q = l + s - l*s
	}
	p := 2*l - q
	hue := func(t float64) float64 {
		if t < 0 {
			t++
		}
		if t > 1 {
			t--
		}
		switch {
		case t < 1.0/6:
			return p + (q-p)*6*t
		case t < 1.0/2:
			return q
		case t < 2.0/3:
			return p + (q-p)*(2.0/3-t)*6
		default:
			return p
		}
	}
	return hue(h + 1.0/3), hue(h), hue(h - 1.0/3)
}

func parseOKLCH(s string) (RGBA, bool) {
	parts, alpha := funcArgs(s)
	if len(parts) < 3 {
		return RGBA{}, false
	}
	l, ok1 := numOrPct(parts[0], 1) // L: % → [0,1], or already 0..1
	c, ok2 := numOrPct(parts[1], 0.4)
	h, ok3 := parseAngle(parts[2])
	if !ok1 || !ok2 || !ok3 {
		return RGBA{}, false
	}
	a := 1.0
	if len(parts) >= 4 {
		a = parseAlpha(parts[3])
	} else if alpha != "" {
		a = parseAlpha(alpha)
	}
	rad := h * math.Pi / 180
	return oklabToRGBA(l, c*math.Cos(rad), c*math.Sin(rad), a), true
}

func parseOKLab(s string) (RGBA, bool) {
	parts, alpha := funcArgs(s)
	if len(parts) < 3 {
		return RGBA{}, false
	}
	l, ok1 := numOrPct(parts[0], 1)
	aa, ok2 := numOrPct(parts[1], 0.4)
	bb, ok3 := numOrPct(parts[2], 0.4)
	if !ok1 || !ok2 || !ok3 {
		return RGBA{}, false
	}
	alp := 1.0
	if len(parts) >= 4 {
		alp = parseAlpha(parts[3])
	} else if alpha != "" {
		alp = parseAlpha(alpha)
	}
	return oklabToRGBA(l, aa, bb, alp), true
}

// oklabToRGBA is Björn Ottosson's OKLab → linear sRGB transform, followed by the
// sRGB transfer function and a gamut clamp. Out-of-gamut OKLCH colours (the palette
// has a few) clamp per channel, which is what a browser paints too.
func oklabToRGBA(L, a, b, alpha float64) RGBA {
	l_ := L + 0.3963377774*a + 0.2158037573*b
	m_ := L - 0.1055613458*a - 0.0638541728*b
	s_ := L - 0.0894841775*a - 1.2914855480*b

	l := l_ * l_ * l_
	m := m_ * m_ * m_
	s := s_ * s_ * s_

	lr := +4.0767416621*l - 3.3077115913*m + 0.2309699292*s
	lg := -1.2684380046*l + 2.6097574011*m - 0.3413193965*s
	lb := -0.0041960863*l - 0.7034186147*m + 1.7076147010*s

	return RGBA{
		R: clamp01(linearToSRGB(lr)),
		G: clamp01(linearToSRGB(lg)),
		B: clamp01(linearToSRGB(lb)),
		A: alpha,
	}
}

func linearToSRGB(c float64) float64 {
	if c <= 0.0031308 {
		return 12.92 * c
	}
	return 1.055*math.Pow(c, 1/2.4) - 0.055
}

// mixOKLab evaluates the two-colour case of CSS color-mix() in the oklab space,
// which is the form Tailwind emits for an opacity modifier
// (`color-mix(in oklab, <color> P%, transparent)`). Weights are normalised and the
// interpolation is alpha-premultiplied, matching the CSS spec closely enough for a
// contrast estimate.
func mixOKLab(c1 RGBA, w1 float64, c2 RGBA, w2 float64) RGBA {
	if w1+w2 == 0 {
		return c1
	}
	total := w1 + w2
	w1 /= total
	w2 /= total

	l1, a1, b1 := rgbaToOKLab(c1)
	l2, a2, b2 := rgbaToOKLab(c2)

	// Premultiply the lab coordinates by alpha, interpolate, then un-premultiply.
	pa := c1.A*w1 + c2.A*w2
	L := (l1*c1.A*w1 + l2*c2.A*w2)
	A := (a1*c1.A*w1 + a2*c2.A*w2)
	B := (b1*c1.A*w1 + b2*c2.A*w2)
	if pa > 0 {
		L /= pa
		A /= pa
		B /= pa
	}
	return oklabToRGBA(L, A, B, pa)
}

// rgbaToOKLab inverts oklabToRGBA (sRGB → linear → OKLab), needed by mixOKLab.
func rgbaToOKLab(c RGBA) (L, a, b float64) {
	lr := srgbToLinear(c.R)
	lg := srgbToLinear(c.G)
	lb := srgbToLinear(c.B)

	l := 0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb
	m := 0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb
	s := 0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb

	l_ := math.Cbrt(l)
	m_ := math.Cbrt(m)
	s_ := math.Cbrt(s)

	return 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
		1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
		0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_
}

func srgbToLinear(c float64) float64 {
	if c <= 0.04045 {
		return c / 12.92
	}
	return math.Pow((c+0.055)/1.055, 2.4)
}

// namedColors covers the CSS keywords likely to appear in an arbitrary value or a
// hand-written token. It is deliberately not the full 148-name list; extend as real
// usage demands. `transparent` is a real value (alpha 0); `currentcolor` and
// `inherit` are intentionally absent — they cannot be resolved statically.
var namedColors = map[string]RGBA{
	"transparent": {0, 0, 0, 0},
	"white":       {1, 1, 1, 1},
	"black":       {0, 0, 0, 1},
	"red":         {1, 0, 0, 1},
	"green":       {0, 128.0 / 255, 0, 1},
	"blue":        {0, 0, 1, 1},
	"yellow":      {1, 1, 0, 1},
	"cyan":        {0, 1, 1, 1},
	"aqua":        {0, 1, 1, 1},
	"magenta":     {1, 0, 1, 1},
	"fuchsia":     {1, 0, 1, 1},
	"gray":        {128.0 / 255, 128.0 / 255, 128.0 / 255, 1},
	"grey":        {128.0 / 255, 128.0 / 255, 128.0 / 255, 1},
	"silver":      {192.0 / 255, 192.0 / 255, 192.0 / 255, 1},
	"maroon":      {128.0 / 255, 0, 0, 1},
	"olive":       {128.0 / 255, 128.0 / 255, 0, 1},
	"lime":        {0, 1, 0, 1},
	"teal":        {0, 128.0 / 255, 128.0 / 255, 1},
	"navy":        {0, 0, 128.0 / 255, 1},
	"purple":      {128.0 / 255, 0, 128.0 / 255, 1},
	"orange":      {1, 165.0 / 255, 0, 1},
}
