package main

import (
	"fmt"
	"math"
	"runtime/debug"
	"time"
)

var (
	uiBg     = rgb{0x12, 0x14, 0x18}
	uiOKFill = rgb{0x18, 0x42, 0x26}
	uiOKEdge = rgb{0x3c, 0xe0, 0x70}
	uiErrFil = rgb{0x54, 0x18, 0x1c}
	uiErrEdg = rgb{0xff, 0x46, 0x46}
	uiFg     = rgb{0xe6, 0xe8, 0xea}
	uiDim    = rgb{0x9a, 0xa2, 0xac}
	uiCyan   = rgb{0x5c, 0xc8, 0xe0}
	uiGreen  = rgb{0x6c, 0xdc, 0x86}
	uiRed    = rgb{0xf0, 0x6b, 0x6b}
)

// Distances between ink, since layout measures a line from the top of a digit
// to the baseline rather than across a cell with accent and descender slack in
// it. Values carried over from spacing cells will look too small here.
const (
	step = 4

	spaceTight = step * 2
	spaceGroup = step * 4
	spaceRow   = step * 8
)

const (
	uiMargin = spaceGroup
	uiPad    = spaceGroup
	uiBorder = step * 3
	pairGap  = spaceGroup
	blockGap = spaceGroup

	btnW         = 300
	btnH         = 80
	holdDuration = time.Second

	versionSpotSide = 200

	chipRadius = spaceTight
	chipBorder = 2

	gridCols   = 2
	chipPadY   = spaceGroup
	chipGap    = spaceTight
	statRowGap = spaceRow
)

type rect struct {
	x, y, w, h int
}

func (r rect) contains(x, y int) bool {
	return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h
}

type display struct {
	fb    *framebuffer
	big   *textFace
	grid  *textFace
	gridB *textFace

	nowPanel   rect
	sincePanel rect
	nowYs      []int
	sinceYs    []int
	resetBtn   rect
	holdStart  time.Time
	holdFrac   float64
	fired      bool

	version     string
	versionSpot rect
	showVersion bool
}

func newDisplay() (*display, error) {
	fb, err := openFramebuffer()
	if err != nil {
		return nil, err
	}
	d := &display{fb: fb, version: buildVersion()}
	for _, spec := range []struct {
		dst  **textFace
		bold bool
		size float64
	}{
		{&d.big, true, 40},
		{&d.grid, false, 34},
		{&d.gridB, true, 34},
	} {
		face, err := loadFace(spec.bold, spec.size)
		if err != nil {
			fb.close()
			return nil, err
		}
		*spec.dst = face
	}
	if d.grid.cellW != d.gridB.cellW {
		fb.close()
		return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d",
			d.grid.cellW, d.gridB.cellW)
	}
	now := []int{d.statsH(d.big, 2), d.chipsH()}
	since := []int{d.statsH(d.gridB, 4), d.countsH(), btnH}

	// One gap for both panels, and vertically the frame is the border alone.
	// Insetting by uiPad as well would add it to the gaps at a panel's ends but
	// not to the ones between blocks, which is not equal spacing however evenly
	// the remainder is divided.
	gaps := len(now) + len(since) + 2
	spare := fb.h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since)
	if spare < 0 {
		fb.close()
		return nil, fmt.Errorf("panel content is %dpx taller than the screen", -spare)
	}
	gap := spare / gaps

	inner := fb.w - 2*uiMargin
	nowH := 2*uiBorder + sum(now) + (len(now)+1)*gap
	sinceH := 2*uiBorder + sum(since) + (len(since)+1)*gap
	d.nowPanel = rect{uiMargin, uiMargin, inner, nowH}
	d.sincePanel = rect{uiMargin, uiMargin + nowH + blockGap, inner, sinceH}

	d.nowYs = stack(d.nowPanel.y+uiBorder+gap, now, gap)
	d.sinceYs = stack(d.sincePanel.y+uiBorder+gap, since, gap)
	d.resetBtn = rect{
		x: d.sincePanel.x + (inner-btnW)/2,
		y: d.sinceYs[len(d.sinceYs)-1],
		w: btnW,
		h: btnH,
	}
	d.versionSpot = rect{fb.w - versionSpotSide, 0, versionSpotSide, versionSpotSide}
	return d, nil
}

func sum(hs []int) int {
	var t int
	for _, h := range hs {
		t += h
	}
	return t
}

func stack(y int, hs []int, gap int) []int {
	ys := make([]int, len(hs))
	for i, h := range hs {
		ys[i] = y
		y += h + gap
	}
	return ys
}

// Lifting or sliding off cancels, and the press has to be released before it
// can arm again.
func (d *display) holdReset(x, y int, down bool, now time.Time) bool {
	if !down {
		d.holdStart, d.holdFrac, d.fired = time.Time{}, 0, false
		return false
	}
	if d.fired || !d.resetBtn.contains(x, y) {
		d.holdStart, d.holdFrac = time.Time{}, 0
		return false
	}
	if d.holdStart.IsZero() {
		d.holdStart = now
	}
	d.holdFrac = now.Sub(d.holdStart).Seconds() / holdDuration.Seconds()
	if d.holdFrac < 1 {
		return false
	}
	d.holdFrac, d.fired, d.holdStart = 0, true, time.Time{}
	return true
}

// Cyan rather than the status colours because it is something to press, not
// something being reported.
func (d *display) drawResetButton() {
	r := d.resetBtn
	d.fb.roundRect(r.x, r.y, r.w, r.h, chipRadius, uiCyan)
	d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, r.w-2*chipBorder, r.h-2*chipBorder,
		chipRadius-chipBorder, uiBg)

	split := r.x + chipBorder
	if d.holdFrac > 0 {
		w := int(float64(r.w-2*chipBorder) * math.Min(d.holdFrac, 1))
		d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, w, r.h-2*chipBorder,
			chipRadius-chipBorder, uiCyan)
		split += w
	}

	label, base := "RESET", uiCyan
	if d.showVersion {
		label, base = d.version, uiDim
	}
	lx := r.x + (r.w-len(label)*d.gridB.cellW)/2
	ly := r.y + (r.h-d.gridB.lineH)/2 - d.gridB.capTop
	// The label straddles the fill, so each glyph takes the colour that reads
	// against whatever is behind it.
	for i, c := range label {
		gx := lx + i*d.gridB.cellW
		col := base
		if gx+d.gridB.cellW/2 < split {
			col = uiBg
		}
		d.gridB.draw(d.fb, gx, ly, string(c), col)
	}
}

// go run never stamps VCS info, so dev builds have no revision to show.
func buildVersion() string {
	info, ok := debug.ReadBuildInfo()
	if !ok {
		return "dev"
	}
	var rev string
	var dirty bool
	for _, s := range info.Settings {
		switch s.Key {
		case "vcs.revision":
			rev = s.Value
		case "vcs.modified":
			dirty = s.Value == "true"
		}
	}
	if rev == "" {
		return "dev"
	}
	if len(rev) > 9 {
		rev = rev[:9]
	}
	if dirty {
		rev += "+"
	}
	return rev
}

func (d *display) close() {
	d.fb.close()
}

// y is the top of the line as read, not the top of the cell, so text and a
// bordered box placed the same distance apart look it.
func (d *display) centerIn(f *textFace, x, w, y int, s string, col rgb) int {
	f.draw(d.fb, x+(w-len([]rune(s))*f.cellW)/2, y-f.capTop, s, col)
	return y + f.lineH + pairGap
}

type statCell struct {
	value string
	label string
	col   rgb
}

func (d *display) statPairH(vf *textFace) int {
	return vf.lineH + pairGap + d.grid.lineH
}

func gridRows(n int) int { return (n + gridCols - 1) / gridCols }

func (d *display) statsH(vf *textFace, n int) int {
	return gridRows(n)*(d.statPairH(vf)+statRowGap) - statRowGap
}

// A last row that does not fill the grid is centred.
func gridCell(i, n, x, w int) (cx, cw int) {
	cw = (w - (gridCols-1)*chipGap) / gridCols
	inRow := min(n-(i/gridCols)*gridCols, gridCols)
	cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%gridCols)*(cw+chipGap)
	return cx, cw
}

// An empty value takes its space without drawing, so nothing below it moves
// when it arrives.
func (d *display) stats(vf *textFace, x, w, y int, cells []statCell) int {
	for i, c := range cells {
		if c.value == "" {
			continue
		}
		cx, cw := gridCell(i, len(cells), x, w)
		cy := y + (i/gridCols)*(d.statPairH(vf)+statRowGap)
		ly := d.centerIn(vf, cx, cw, cy, c.value, c.col)
		d.centerIn(d.grid, cx, cw, ly, c.label, uiDim)
	}
	return y + d.statsH(vf, len(cells))
}

var errRows = []struct {
	label string
	get   func(errs) uint64
}{
	{"lost", func(e errs) uint64 { return e.lost }},
	{"corrupt", func(e errs) uint64 { return e.corrupt }},
	{"link", func(e errs) uint64 { return e.link }},
	{"internal", func(e errs) uint64 { return e.internal }},
}

func (d *display) chipH() int { return d.grid.lineH + 2*chipPadY }

func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + pairGap }

// The error chips plus the noise cable's, which shares their row grid.
func (d *display) chipsH() int {
	return gridRows(len(errRows)+1)*(d.chipH()+chipGap) - chipGap
}

func (d *display) countsH() int {
	return gridRows(len(errRows))*(d.countChipH()+chipGap) - chipGap
}

// Outlined by drawing the border colour and sinking a smaller well of
// background into it, so both curves get the same antialiasing.
func (d *display) chipAt(i, n, x, w, y, h int, c rgb) (int, int, int) {
	cx, cw := gridCell(i, n, x, w)
	cy := y + (i/gridCols)*(h+chipGap)

	d.fb.roundRect(cx, cy, cw, h, chipRadius, c)
	d.fb.roundRect(cx+chipBorder, cy+chipBorder,
		cw-2*chipBorder, h-2*chipBorder, chipRadius-chipBorder, uiBg)
	return cx, cw, cy
}

// Whether rather than how many: over a window this short a count changes faster
// than it can be read. The noise chip rides along at the end, presence rather
// than health: red is the cable missing, not the cable failing.
func (d *display) errChips(x, w, y int, e errs, noiseMissing uint64) int {
	n := len(errRows) + 1
	for i, r := range errRows {
		c := errColor(r.get(e))
		cx, cw, cy := d.chipAt(i, n, x, w, y, d.chipH(), c)
		d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c)
	}
	c := errColor(noiseMissing)
	cx, cw, cy := d.chipAt(len(errRows), n, x, w, y, d.chipH(), c)
	d.centerIn(d.grid, cx, cw, cy+chipPadY, "noise", c)
	return y + d.chipsH()
}

func (d *display) errCounts(x, w, y int, e errs) int {
	for i, r := range errRows {
		n := r.get(e)
		c := errColor(n)
		cx, cw, cy := d.chipAt(i, len(errRows), x, w, y, d.countChipH(), c)
		ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c)
		d.centerIn(d.grid, cx, cw, ty, r.label, c)
	}
	return y + d.countsH()
}

func (d *display) panel(p rect, e errs) (int, int) {
	fill, edge := uiOKFill, uiOKEdge
	if e.total() > 0 {
		fill, edge = uiErrFil, uiErrEdg
	}
	d.fb.rect(p.x, p.y, p.w, p.h, edge)
	d.fb.rect(p.x+uiBorder, p.y+uiBorder,
		p.w-2*uiBorder, p.h-2*uiBorder, fill)

	inset := uiBorder + uiPad
	return p.x + inset, p.w - 2*inset
}

func errColor(n uint64) rgb {
	if n == 0 {
		return uiGreen
	}
	return uiRed
}

func (d *display) render(v view, elapsed time.Duration, cable string, noiseMissing uint64) error {
	fb := d.fb
	fb.fill(uiBg)

	x, w := d.panel(d.nowPanel, v.window)
	d.stats(d.big, x, w, d.nowYs[0], []statCell{
		{scaleSI(v.rxGbps * 1e9), "bits/s", uiFg},
		{scaleSI(v.rxPPS), "packets/s", uiFg},
	})
	d.errChips(x, w, d.nowYs[1], v.window, noiseMissing)

	x, w = d.panel(d.sincePanel, v.since)
	d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{
		{scaleTime(elapsed), "elapsed", uiFg},
		{scaleCount(v.rxFrames), "packets", uiFg},
		{scaleCount(v.rxBytes), "bytes", uiFg},
		{cable, "m", uiFg},
	})
	d.errCounts(x, w, d.sinceYs[1], v.since)

	d.drawResetButton()
	return fb.flush()
}
