// Package sparkline renders inline SVG sparklines and trend
// charts for the dashboard and host repo page. All output is
// pure server-rendered SVG with no JavaScript, no external
// stylesheet, and no client library.
package sparkline

import (
	"fmt"
	"html/template"
	"math"
	"strings"
	"time"
)

// RenderSparkline returns an inline SVG <svg> element of the
// given size containing a single polyline normalised across the
// full y-range of points. NaN entries break the polyline. With
// fewer than two real points the SVG still renders but contains
// only a faint baseline + an em-dash placeholder.
func RenderSparkline(points []float64, width, height int) template.HTML {
	const pad = 2
	w := width
	h := height
	innerW := w - 2*pad
	innerH := h - 2*pad

	var real []float64
	for _, p := range points {
		if !math.IsNaN(p) {
			real = append(real, p)
		}
	}

	var b strings.Builder
	fmt.Fprintf(&b,
		`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-sparkline" role="img" aria-label="repo size trend">`,
		w, h)

	if len(real) < 2 {
		fmt.Fprintf(&b,
			`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="2,2"/>`,
			pad, h/2, w-pad, h/2)
		fmt.Fprintf(&b,
			`<text x="%d" y="%d" text-anchor="middle" font-size="%d" fill="currentColor" fill-opacity="0.4">—</text>`,
			w/2, h/2+4, h-6)
		b.WriteString(`</svg>`)
		return template.HTML(b.String())
	}

	min, max := real[0], real[0]
	for _, v := range real {
		if v < min {
			min = v
		}
		if v > max {
			max = v
		}
	}
	span := max - min
	if span == 0 {
		span = 1
	}

	stepX := 0.0
	if len(points) > 1 {
		stepX = float64(innerW) / float64(len(points)-1)
	}

	var seg strings.Builder
	flush := func() {
		if seg.Len() == 0 {
			return
		}
		fmt.Fprintf(&b,
			`<polyline fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
			strings.TrimSpace(seg.String()))
		seg.Reset()
	}
	for i, v := range points {
		if math.IsNaN(v) {
			flush()
			continue
		}
		x := float64(pad) + stepX*float64(i)
		y := float64(pad) + float64(innerH) - (v-min)/span*float64(innerH)
		fmt.Fprintf(&seg, "%.2f,%.2f ", x, y)
	}
	flush()

	cur := real[len(real)-1]
	first := real[0]
	delta := cur - first
	sign := "+"
	if delta < 0 {
		sign = "-"
		delta = -delta
	}
	fmt.Fprintf(&b, `<title>current %.0f, %s%.0f over window</title>`, cur, sign, delta)

	b.WriteString(`</svg>`)
	return template.HTML(b.String())
}

// Axis selects which y-axis a Series is normalised against.
type Axis int

const (
	// AxisLeft maps the series to the left y-axis.
	AxisLeft Axis = iota
	// AxisRight maps the series to the right y-axis.
	AxisRight
)

// Format selects how a Series' values appear in hover tooltips.
type Format int

const (
	// FormatBytes formats a value as a human-readable byte size.
	FormatBytes Format = iota
	// FormatCount formats a value as an integer count.
	FormatCount
)

// Series is one labelled trace on a chart.
type Series struct {
	Name   string
	Points []float64 // NaN breaks the polyline
	Stroke string    // hex colour
	Axis   Axis
	Format Format
}

// ChartOpts controls rendering of the full trend chart.
type ChartOpts struct {
	Width      int
	Height     int
	GridBands  int    // default 4
	EmptyLabel string // default "no data yet"
}

// RenderChart returns an inline SVG <svg> with up to two y-axes,
// one polyline per series, hover-dot per data point, and X-axis
// labels at start / midpoint / end. With no series or empty
// series, renders a faint baseline + EmptyLabel centred. Points
// beyond len(days) are ignored.
func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTML {
	if opts.Width <= 0 {
		opts.Width = 600
	}
	if opts.Height <= 0 {
		opts.Height = 220
	}
	if opts.GridBands <= 0 {
		opts.GridBands = 4
	}
	if opts.EmptyLabel == "" {
		opts.EmptyLabel = "no data yet"
	}
	const padL, padR, padT, padB = 72, 56, 16, 28
	w, h := opts.Width, opts.Height
	innerW := w - padL - padR
	innerH := h - padT - padB

	var b strings.Builder
	fmt.Fprintf(&b,
		`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time">`,
		w, h)

	hasData := false
	for _, s := range series {
		for _, p := range s.Points {
			if !math.IsNaN(p) {
				hasData = true
				break
			}
		}
		if hasData {
			break
		}
	}
	if !hasData || len(days) == 0 {
		fmt.Fprintf(&b,
			`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/>`,
			padL, h/2, w-padR, h/2)
		fmt.Fprintf(&b,
			`<text x="%d" y="%d" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">%s</text>`,
			w/2, h/2+4, opts.EmptyLabel)
		b.WriteString(`</svg>`)
		return template.HTML(b.String())
	}

	for i := 0; i <= opts.GridBands; i++ {
		y := padT + innerH*i/opts.GridBands
		fmt.Fprintf(&b,
			`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.08"/>`,
			padL, y, w-padR, y)
	}

	type axBounds struct {
		min, max float64
		has      bool
	}
	// Use fixed-order array to avoid map iteration non-determinism.
	var axArr [2]axBounds
	for _, s := range series {
		a := &axArr[s.Axis]
		for _, p := range s.Points {
			if math.IsNaN(p) {
				continue
			}
			if !a.has {
				a.min, a.max, a.has = p, p, true
				continue
			}
			if p < a.min {
				a.min = p
			}
			if p > a.max {
				a.max = p
			}
		}
	}
	for i := range axArr {
		if axArr[i].has && axArr[i].max == axArr[i].min {
			axArr[i].max = axArr[i].min + 1
		}
	}

	stepX := 0.0
	if len(days) > 1 {
		stepX = float64(innerW) / float64(len(days)-1)
	}

	for _, s := range series {
		a := &axArr[s.Axis]
		if !a.has {
			continue
		}
		var seg strings.Builder
		flush := func() {
			if seg.Len() == 0 {
				return
			}
			fmt.Fprintf(&b,
				`<polyline fill="none" stroke="%s" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
				s.Stroke, strings.TrimSpace(seg.String()))
			seg.Reset()
		}
		for i, v := range s.Points {
			if i >= len(days) {
				break
			}
			if math.IsNaN(v) {
				flush()
				continue
			}
			x := float64(padL) + stepX*float64(i)
			if len(days) == 1 {
				// Single-day: pin the lone dot to the chart centre so it
				// sits under the centred date label.
				x = float64(padL) + float64(innerW)/2
			}
			y := float64(padT) + float64(innerH) - (v-a.min)/(a.max-a.min)*float64(innerH)
			fmt.Fprintf(&seg, "%.2f,%.2f ", x, y)
			d := days[i]
			fmt.Fprintf(&b,
				`<circle cx="%.2f" cy="%.2f" r="2.5" fill="%s"><title>%s · %s: %s</title></circle>`,
				x, y, s.Stroke, d.Format("2006-01-02"), s.Name, formatValue(v, s.Format))
		}
		flush()
	}

	if axArr[AxisLeft].has {
		writeAxisLabels(&b, padL-6, padT, innerH, axArr[AxisLeft].min, axArr[AxisLeft].max, FormatBytes, "end")
		// Rotated axis title in the left margin. Position inset from
		// the viewBox edge by ≈ font-size so the rotated glyph extents
		// don't clip against the SVG boundary.
		cy := padT + innerH/2
		fmt.Fprintf(&b,
			`<text x="14" y="%d" text-anchor="middle" font-size="11" fill="currentColor" fill-opacity="0.55" transform="rotate(-90, 14, %d)">Size</text>`,
			cy, cy)
	}
	if axArr[AxisRight].has {
		writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start")
		cy := padT + innerH/2
		fmt.Fprintf(&b,
			`<text x="%d" y="%d" text-anchor="middle" font-size="11" fill="currentColor" fill-opacity="0.55" transform="rotate(90, %d, %d)">Snapshots</text>`,
			w-14, cy, w-14, cy)
	}

	// X-axis labels at start / mid / end. With 1-2 days the indices
	// collapse onto each other — dedupe so we don't stack overlapping
	// "Jan 2" labels at the same x coordinate.
	type xLabel struct {
		idx    int
		anchor string
	}
	var xLabels []xLabel
	switch {
	case len(days) == 1:
		xLabels = []xLabel{{0, "middle"}}
	case len(days) == 2:
		xLabels = []xLabel{{0, "start"}, {1, "end"}}
	default:
		xLabels = []xLabel{{0, "start"}, {len(days) / 2, "middle"}, {len(days) - 1, "end"}}
	}
	for _, l := range xLabels {
		x := float64(padL) + stepX*float64(l.idx)
		// With a single point, anchor "middle" centres on padL — push to
		// the chart's centre line so the lone label sits over the dot.
		if len(days) == 1 {
			x = float64(padL) + float64(innerW)/2
		}
		fmt.Fprintf(&b,
			`<text x="%.2f" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
			x, h-padB+16, l.anchor, days[l.idx].Format("Jan 2"))
	}

	b.WriteString(`</svg>`)
	return template.HTML(b.String())
}

func writeAxisLabels(b *strings.Builder, x, padT, innerH int, min, max float64, f Format, anchor string) {
	const bands = 4
	for i := 0; i <= bands; i++ {
		y := padT + innerH*i/bands
		v := max - (max-min)*float64(i)/float64(bands)
		fmt.Fprintf(b,
			`<text x="%d" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
			x, y+3, anchor, formatValue(v, f))
	}
}

func formatValue(v float64, f Format) string {
	switch f {
	case FormatBytes:
		return humanBytes(v)
	default:
		return fmt.Sprintf("%.0f", v)
	}
}

func humanBytes(v float64) string {
	const k = 1024.0
	units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
	i := 0
	for v >= k && i < len(units)-1 {
		v /= k
		i++
	}
	if v >= 100 {
		return fmt.Sprintf("%.0f %s", v, units[i])
	}
	return fmt.Sprintf("%.1f %s", v, units[i])
}
