package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html/template"
	"log"
	"math"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

func init() {
	pageTypeHandlers = append(pageTypeHandlers, &fitnessHandler{})
}

// waistlineExportFile is the exact filename the user copies into the folder.
// The single file is always the latest export — no glob, no multi-file merge.
const waistlineExportFile = "waistline_export.json"

type fitnessHandler struct{}

// redirect: the fitness dashboard has no virtual URLs; everything renders
// inside the normal GET /{path}/ flow.
func (f *fitnessHandler) redirect(root, fsPath, urlPath string, r *http.Request) (string, bool) {
	return "", false
}

// handle renders the dashboard for folders whose .page-settings declares
// type = fitness. Markdown content and the folder listing stay visible so
// the user can verify an uploaded export arrived.
func (f *fitnessHandler) handle(root, fsPath, urlPath string, r *http.Request) *specialPage {
	s := readPageSettings(fsPath)
	if s == nil || s.Type != "fitness" {
		return nil
	}
	weightSel := validFitnessRange(r.URL.Query().Get("weight"), "3m")
	weeklySel := validFitnessRange(r.URL.Query().Get("weekly"), "1y")
	return &specialPage{
		Content:     renderFitnessDashboard(fsPath, weightSel, weeklySel),
		SuppressTOC: true,
	}
}

// === Time ranges ===

type fitnessRange struct {
	Value  string
	Label  string
	Months int // 0 = all data
}

var fitnessRanges = []fitnessRange{
	{"1m", "1 month", 1},
	{"3m", "3 months", 3},
	{"1y", "1 year", 12},
	{"all", "All", 0},
}

func validFitnessRange(v, fallback string) string {
	for _, r := range fitnessRanges {
		if r.Value == v {
			return v
		}
	}
	return fallback
}

func rangeMonths(v string) int {
	for _, r := range fitnessRanges {
		if r.Value == v {
			return r.Months
		}
	}
	return 0
}

// === Waistline export parsing ===

// wlNum is a number in a Waistline export: values appear as JSON numbers,
// numeric strings, null, or are absent. Unparsable values read as not-ok
// instead of failing the whole export parse.
type wlNum struct {
	val float64
	ok  bool
}

func (n *wlNum) UnmarshalJSON(b []byte) error {
	s := strings.Trim(string(b), `"`)
	if s == "" || s == "null" {
		return nil
	}
	v, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return nil
	}
	n.val = v
	n.ok = true
	return nil
}

// Calorie tracking was removed pending a rethink of the (undocumented)
// per-item formula; only the weight series is read from the export.
type wlExport struct {
	Diary    []wlDiaryEntry  `json:"diary"`
	Settings json.RawMessage `json:"settings"`
}

type wlDiaryEntry struct {
	DateTime string `json:"dateTime"`
	Stats    struct {
		Weight wlNum `json:"weight"`
	} `json:"stats"`
}

// exportDate extracts the calendar day from a Waistline dateTime. The values
// are UTC-midnight timestamps; taking the first 10 characters avoids time
// zone conversions shifting the day.
func exportDate(s string) (time.Time, bool) {
	if len(s) < 10 {
		return time.Time{}, false
	}
	t, err := time.Parse("2006-01-02", s[:10])
	if err != nil {
		return time.Time{}, false
	}
	return t, true
}

// goalValue extracts settings.goals.<key>.goal-list[0].goal[0] — the first
// weekday slot of the shared goal. Best-effort: any missing or unparsable
// level means "no goal line", never an error.
func goalValue(settings json.RawMessage, key string) (float64, bool) {
	var s struct {
		Goals map[string]json.RawMessage `json:"goals"`
	}
	if json.Unmarshal(settings, &s) != nil {
		return 0, false
	}
	var g struct {
		GoalList []struct {
			Goal []wlNum `json:"goal"`
		} `json:"goal-list"`
	}
	if json.Unmarshal(s.Goals[key], &g) != nil {
		return 0, false
	}
	if len(g.GoalList) == 0 || len(g.GoalList[0].Goal) == 0 {
		return 0, false
	}
	n := g.GoalList[0].Goal[0]
	return n.val, n.ok
}

func exportWeightUnit(settings json.RawMessage) string {
	var s struct {
		Units struct {
			Weight string `json:"weight"`
		} `json:"units"`
	}
	if json.Unmarshal(settings, &s) == nil && s.Units.Weight != "" {
		return s.Units.Weight
	}
	return "kg"
}

// === Series extraction ===

type weightPoint struct {
	date  time.Time
	value float64
	days  int // >0: weekly mean over this many measured days
}

// extractWeights computes the weight series from the export: one point per
// diary entry with stats.weight. Duplicate dates (shouldn't happen) — last
// one wins.
func extractWeights(ex *wlExport) []weightPoint {
	byDate := map[time.Time]float64{}
	for _, e := range ex.Diary {
		day, ok := exportDate(e.DateTime)
		if !ok || !e.Stats.Weight.ok {
			continue
		}
		byDate[day] = e.Stats.Weight.val
	}
	weights := make([]weightPoint, 0, len(byDate))
	for d, v := range byDate {
		weights = append(weights, weightPoint{date: d, value: v})
	}
	sort.Slice(weights, func(i, j int) bool { return weights[i].date.Before(weights[j].date) })
	return weights
}

func mondayOf(t time.Time) time.Time {
	return t.AddDate(0, 0, -((int(t.Weekday()) + 6) % 7))
}

// weeklyMeanWeights buckets the daily weight series into ISO weeks (Monday
// start) and averages over the days that have a measurement. Weeks without
// any measurement produce no point.
func weeklyMeanWeights(points []weightPoint) []weightPoint {
	type acc struct {
		sum float64
		n   int
	}
	byWeek := map[time.Time]*acc{}
	for _, p := range points {
		w := mondayOf(p.date)
		a := byWeek[w]
		if a == nil {
			a = &acc{}
			byWeek[w] = a
		}
		a.sum += p.value
		a.n++
	}
	out := make([]weightPoint, 0, len(byWeek))
	for w, a := range byWeek {
		out = append(out, weightPoint{date: w, value: a.sum / float64(a.n), days: a.n})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].date.Before(out[j].date) })
	return out
}

// === SVG geometry ===

// Chart canvas in viewBox units. The SVG scales to container width via
// viewBox + width:100%, so these only set proportions and text size.
const (
	chartW      = 560.0
	chartH      = 240.0
	chartLeft   = 46.0
	chartRight  = 8.0
	chartTop    = 10.0
	chartBottom = 24.0
)

// svgNum formats a coordinate or display value: rounded to 2 decimals,
// trailing zeros trimmed.
func svgNum(v float64) string {
	return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)
}

type chartScale struct {
	x0, x1 time.Time
	y0, y1 float64
}

func (s chartScale) x(t time.Time) float64 {
	span := s.x1.Sub(s.x0).Seconds()
	if span <= 0 {
		return chartLeft
	}
	return chartLeft + (chartW-chartLeft-chartRight)*t.Sub(s.x0).Seconds()/span
}

func (s chartScale) y(v float64) float64 {
	span := s.y1 - s.y0
	if span <= 0 {
		return chartH - chartBottom
	}
	return chartH - chartBottom - (chartH-chartBottom-chartTop)*(v-s.y0)/span
}

// === View models ===

type fitnessOptVM struct {
	Value, Label string
	Selected     bool
}

type fitnessTickVM struct {
	Pos, Label, Anchor string
}

type fitnessDotVM struct {
	X, Y, Title string
}

type fitnessGoalVM struct {
	Y, LabelY, Label string
}

type fitnessChartVM struct {
	Title   string
	Param   string
	Options []fitnessOptVM
	Empty   bool

	ViewW, ViewH               string
	PlotX, PlotY, PlotR, PlotB string
	YLabelX, XLabelY           string
	YTicks, XTicks             []fitnessTickVM
	Goal                       *fitnessGoalVM
	Lines                      []string // polyline points attributes
	Dots                       []fitnessDotVM
}

type fitnessDashVM struct {
	Notice string
	Charts []fitnessChartVM
}

func newChartVM(title, param, sel string) fitnessChartVM {
	opts := make([]fitnessOptVM, len(fitnessRanges))
	for i, r := range fitnessRanges {
		opts[i] = fitnessOptVM{r.Value, r.Label, r.Value == sel}
	}
	return fitnessChartVM{
		Title: title, Param: param, Options: opts,
		ViewW: svgNum(chartW), ViewH: svgNum(chartH),
		PlotX: svgNum(chartLeft), PlotY: svgNum(chartTop),
		PlotR: svgNum(chartW - chartRight), PlotB: svgNum(chartH - chartBottom),
		YLabelX: svgNum(chartLeft - 5), XLabelY: svgNum(chartH - chartBottom + 14),
	}
}

// yTickVMs places gridlines at fixed multiples of step across the y domain
// (always 5 kg guides on weight charts, regardless of range).
func yTickVMs(sc chartScale, step float64) []fitnessTickVM {
	var out []fitnessTickVM
	for v := math.Ceil(sc.y0/step) * step; v <= sc.y1+step/1e6; v += step {
		out = append(out, fitnessTickVM{Pos: svgNum(sc.y(v)), Label: svgNum(v)})
	}
	return out
}

func (vm *fitnessChartVM) setGoal(sc chartScale, goal float64, label string) {
	gy := sc.y(goal)
	ly := gy - 4
	if ly < chartTop+10 {
		ly = gy + 12
	}
	vm.Goal = &fitnessGoalVM{Y: svgNum(gy), LabelY: svgNum(ly), Label: label}
}

// === Chart builders ===

// buildWeightChart renders a weight line chart. It serves both the per-day
// series and the weekly-mean series (points carrying days > 0); the line is
// drawn continuous across days/weeks without a measurement.
func buildWeightChart(all []weightPoint, goal float64, hasGoal bool, sel, param, title, unit string, today time.Time) fitnessChartVM {
	vm := newChartVM(title, param, sel)

	points := all
	var x0, x1 time.Time
	if m := rangeMonths(sel); m > 0 {
		x0 = today.AddDate(0, -m, 0)
		points = filterWeights(all, x0)
		if len(points) == 0 {
			vm.Empty = true
			return vm
		}
		x1 = today
		if last := points[len(points)-1].date; last.After(x1) {
			x1 = last
		}
	} else {
		if len(points) == 0 {
			vm.Empty = true
			return vm
		}
		x0 = points[0].date
		x1 = points[len(points)-1].date
	}
	// Degenerate domain (single point on All): widen so the dot sits inside
	// the plot instead of on its edge.
	if !x0.Before(x1) {
		x0 = x0.AddDate(0, 0, -1)
		x1 = x1.AddDate(0, 0, 1)
	}

	lo, hi := points[0].value, points[0].value
	for _, p := range points {
		lo = min(lo, p.value)
		hi = max(hi, p.value)
	}
	if hasGoal {
		lo = min(lo, goal)
		hi = max(hi, goal)
	}
	pad := (hi - lo) * 0.05
	if pad == 0 {
		pad = 1
	}
	sc := chartScale{x0, x1, lo - pad, hi + pad}

	vm.YTicks = yTickVMs(sc, 5)
	vm.XTicks = timeXTicks(sc, 4)

	// One continuous polyline through every point in range — days without a
	// measurement do not break the line. Point markers carry the hover
	// <title>; on dense ranges they are dropped and the bare line stays
	// legible. A single point in range renders as a dot.
	dot := func(p weightPoint) fitnessDotVM {
		label := p.date.Format("2006-01-02") + ": " + svgNum(p.value) + " " + unit
		if p.days > 0 {
			label = fmt.Sprintf("Week of %s: %s %s (%d days)",
				p.date.Format("2006-01-02"), svgNum(p.value), unit, p.days)
		}
		return fitnessDotVM{X: svgNum(sc.x(p.date)), Y: svgNum(sc.y(p.value)), Title: label}
	}
	if len(points) >= 2 {
		var b strings.Builder
		for i, p := range points {
			if i > 0 {
				b.WriteByte(' ')
			}
			b.WriteString(svgNum(sc.x(p.date)))
			b.WriteByte(',')
			b.WriteString(svgNum(sc.y(p.value)))
		}
		vm.Lines = append(vm.Lines, b.String())
	}
	if len(points) <= 100 {
		for _, p := range points {
			vm.Dots = append(vm.Dots, dot(p))
		}
	}

	if hasGoal {
		vm.setGoal(sc, goal, "goal "+svgNum(goal))
	}
	return vm
}

func filterWeights(points []weightPoint, from time.Time) []weightPoint {
	var out []weightPoint
	for _, p := range points {
		if !p.date.Before(from) {
			out = append(out, p)
		}
	}
	return out
}

// timeXTicks places n+1 evenly spaced date labels across a continuous time
// axis; the last label is end-anchored so it stays inside the viewBox.
func timeXTicks(sc chartScale, n int) []fitnessTickVM {
	span := sc.x1.Sub(sc.x0)
	var out []fitnessTickVM
	prev := ""
	for i := 0; i <= n; i++ {
		t := sc.x0.Add(time.Duration(float64(span) * float64(i) / float64(n)))
		label := t.Format("2006-01-02")
		if label == prev {
			continue
		}
		prev = label
		anchor := "middle"
		if i == n {
			anchor = "end"
		}
		out = append(out, fitnessTickVM{Pos: svgNum(sc.x(t)), Label: label, Anchor: anchor})
	}
	return out
}

// === Rendering ===

var fitnessTmpl = template.Must(template.ParseFS(assets, "assets/fitness/main.html"))

func renderFitnessDashboard(fsPath, weightSel, weeklySel string) template.HTML {
	data := buildFitnessDash(fsPath, weightSel, weeklySel, time.Now())
	var buf bytes.Buffer
	if err := fitnessTmpl.Execute(&buf, data); err != nil {
		log.Printf("fitness template: %v", err)
		return ""
	}
	return template.HTML(buf.String())
}

// buildFitnessDash reads and parses the export per request — no caching, no
// indexes. A read or parse failure (including a truncated mid-upload file)
// becomes an inline notice; the page itself always renders.
func buildFitnessDash(fsPath, weightSel, weeklySel string, now time.Time) fitnessDashVM {
	raw, err := os.ReadFile(filepath.Join(fsPath, waistlineExportFile))
	if err != nil {
		return fitnessDashVM{Notice: "No Waistline export found — upload " + waistlineExportFile + " to this folder."}
	}
	var ex wlExport
	if err := json.Unmarshal(raw, &ex); err != nil {
		return fitnessDashVM{Notice: "Could not read " + waistlineExportFile + ": " + err.Error()}
	}

	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	weights := extractWeights(&ex)
	wGoal, hasWGoal := goalValue(ex.Settings, "weight")
	unit := exportWeightUnit(ex.Settings)

	return fitnessDashVM{Charts: []fitnessChartVM{
		buildWeightChart(weights, wGoal, hasWGoal, weightSel,
			"weight", "Weight ("+unit+")", unit, today),
		buildWeightChart(weeklyMeanWeights(weights), wGoal, hasWGoal, weeklySel,
			"weekly", "Weekly average weight ("+unit+")", unit, today),
	}}
}
