package catalog_test

import (
	"encoding/json"
	"testing"
	"time"

	"git.vakhrushev.me/av/healthlog/internal/catalog"
	"git.vakhrushev.me/av/healthlog/internal/store"
)

func hour(n int) time.Time {
	return time.Date(2026, 8, 1, n, 0, 0, 0, time.UTC).UTC()
}

// pair собирает час: одна часовая точка на границе часа и минутные точки внутри.
func pair(h time.Time, coarse float64, fine ...float64) store.HourPair {
	p := store.HourPair{
		Hour:   h,
		Coarse: []store.Point{{Start: h, End: h, Raw: qty(coarse)}},
	}
	for i, v := range fine {
		at := h.Add(time.Duration(i) * time.Minute)
		p.Fine = append(p.Fine, store.Point{Start: at, End: at, Raw: qty(v)})
	}
	return p
}

func qty(v float64) json.RawMessage {
	b, err := json.Marshal(map[string]float64{"qty": v})
	if err != nil {
		panic(err)
	}
	return b
}

// window собирает окно из одинаковых по устройству часов, от свежих к старым —
// в том же порядке, в каком их отдаёт хранилище.
func window(n int, f func(h time.Time) store.HourPair) []store.HourPair {
	out := make([]store.HourPair, 0, n)
	for i := n - 1; i >= 0; i-- {
		out = append(out, f(hour(i)))
	}
	return out
}

func TestMeasureНакопительная(t *testing.T) {
	t.Parallel()

	pairs := window(4, func(h time.Time) store.HourPair {
		return pair(h, 6, 1, 2, 3)
	})

	style, basis := catalog.Measure(pairs)
	if style != catalog.Cumulative {
		t.Fatalf("род: получили %v, ждали cumulative", style)
	}
	if basis.Agreeing != 4 || basis.Conflicting != 0 || basis.Compared != 4 || basis.Hours != 4 {
		t.Errorf("основание: %+v", basis)
	}
	if basis.FirstHour == nil || !basis.FirstHour.Equal(hour(0)) {
		t.Errorf("начало окна: %v", basis.FirstHour)
	}
	if basis.LastHour == nil || !basis.LastHour.Equal(hour(3)) {
		t.Errorf("конец окна: %v", basis.LastHour)
	}
}

func TestMeasureМгновенная(t *testing.T) {
	t.Parallel()

	pairs := window(4, func(h time.Time) store.HourPair {
		return pair(h, 2, 1, 2, 3)
	})

	style, basis := catalog.Measure(pairs)
	if style != catalog.Instant {
		t.Fatalf("род: получили %v, ждали instant", style)
	}
	if basis.Agreeing != 4 || basis.Conflicting != 0 {
		t.Errorf("основание: %+v", basis)
	}
}

// Нулевой час различить гипотезы не может: сумма равна среднему. Без этого
// фильтра `walking_asymmetry_percentage` на живом корпусе давала конфликт из
// одних нулевых часов.
func TestMeasureНулевойЧасНеСвидетельствует(t *testing.T) {
	t.Parallel()

	pairs := window(5, func(h time.Time) store.HourPair {
		return pair(h, 0, 0, 0, 0)
	})

	style, basis := catalog.Measure(pairs)
	if style != catalog.Unknown {
		t.Fatalf("род: получили %v, ждали unknown", style)
	}
	if basis.Compared != 0 || basis.Hours != 5 {
		t.Errorf("основание: %+v — нулевые часы обязаны быть непригодны", basis)
	}
}

func TestMeasureПротиворечиеГаситРод(t *testing.T) {
	t.Parallel()

	pairs := []store.HourPair{
		pair(hour(4), 6, 1, 2, 3),  // сумма
		pair(hour(3), 6, 1, 2, 3),  // сумма
		pair(hour(2), 6, 1, 2, 3),  // сумма
		pair(hour(1), 2, 1, 2, 3),  // среднее
		pair(hour(0), 12, 1, 2, 3), // ни то ни то
	}

	style, basis := catalog.Measure(pairs)
	if style != catalog.Unknown {
		t.Fatalf("род: получили %v, ждали unknown при противоречии", style)
	}
	if basis.Agreeing != 3 || basis.Conflicting != 1 {
		t.Errorf("основание: %+v", basis)
	}
	if basis.Compared != 5 {
		t.Errorf("пригодных часов: %d, ждали 5", basis.Compared)
	}
}

func TestMeasureМалоСвидетельств(t *testing.T) {
	t.Parallel()

	pairs := window(2, func(h time.Time) store.HourPair {
		return pair(h, 6, 1, 2, 3)
	})

	style, basis := catalog.Measure(pairs)
	if style != catalog.Unknown {
		t.Fatalf("род: получили %v, ждали unknown при двух согласных часах", style)
	}
	if basis.Agreeing != 2 {
		t.Errorf("согласных: %d", basis.Agreeing)
	}
}

func TestMeasureОбщихЧасовНет(t *testing.T) {
	t.Parallel()

	style, basis := catalog.Measure(nil)
	if style != catalog.Unknown {
		t.Fatalf("род: получили %v", style)
	}
	if basis.Hours != 0 || basis.FirstHour != nil || basis.LastHour != nil {
		t.Errorf("основание: %+v — границ окна быть не должно", basis)
	}
}

func TestMeasureНепригодныеЧасы(t *testing.T) {
	t.Parallel()

	двеЧасовые := func(h time.Time) store.HourPair {
		p := pair(h, 6, 1, 2, 3)
		p.Coarse = append(p.Coarse, store.Point{Start: h, End: h.Add(time.Hour), Raw: qty(6)})
		return p
	}
	однаМинутная := func(h time.Time) store.HourPair {
		return pair(h, 1, 1)
	}
	// Метка часовой точки на середине часа UTC: так выглядит часовой слой в
	// зоне с получасовым смещением, и сравнивать его с минутными точками того
	// же объекта нельзя — они покрывают другой интервал.
	сдвинутая := func(h time.Time) store.HourPair {
		p := pair(h, 6, 1, 2, 3)
		p.Coarse[0].Start = h.Add(30 * time.Minute)
		return p
	}
	безЧисла := func(h time.Time) store.HourPair {
		p := pair(h, 6, 1, 2, 3)
		p.Coarse[0].Raw = json.RawMessage(`{"date":"…"}`)
		return p
	}

	cases := map[string]func(time.Time) store.HourPair{
		"часовой объект несёт две точки": двеЧасовые,
		"минутный объект несёт одну":     однаМинутная,
		"часовая метка не выровнена":     сдвинутая,
		"часовая точка без числа":        безЧисла,
	}

	for name, mk := range cases {
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			style, basis := catalog.Measure(window(5, mk))
			if style != catalog.Unknown {
				t.Errorf("род: получили %v, ждали unknown", style)
			}
			if basis.Compared != 0 {
				t.Errorf("пригодных часов: %d, ждали 0 (основание %+v)", basis.Compared, basis)
			}
			if basis.Hours != 5 {
				t.Errorf("часов окна: %d, ждали 5", basis.Hours)
			}
		})
	}
}

// Вердикт обязан быть функцией состава, а не порядка точек внутри объекта:
// порядок ключей и элементов в теле HAE нестабилен (находка 2).
func TestMeasureНеЗависитОтПорядкаТочек(t *testing.T) {
	t.Parallel()

	forward := window(4, func(h time.Time) store.HourPair {
		return pair(h, 6, 1, 2, 3)
	})
	backward := window(4, func(h time.Time) store.HourPair {
		p := pair(h, 6, 1, 2, 3)
		p.Fine[0], p.Fine[2] = p.Fine[2], p.Fine[0]
		return p
	})

	s1, b1 := catalog.Measure(forward)
	s2, b2 := catalog.Measure(backward)
	// Границы окна сравниваются значением: в основании они указатели, чтобы
	// «окна не было» отличалось от нулевой метки в ответе.
	same := s1 == s2 &&
		b1.Hours == b2.Hours && b1.Compared == b2.Compared &&
		b1.Agreeing == b2.Agreeing && b1.Conflicting == b2.Conflicting &&
		b1.FirstHour.Equal(*b2.FirstHour) && b1.LastHour.Equal(*b2.LastHour)
	if !same {
		t.Errorf("перестановка точек изменила исход: %v %+v против %v %+v", s1, b1, s2, b2)
	}
}

// Точка без числа в сумму не входит и числа точек часа не увеличивает: час из
// одной значащей точки и одной пустой различить гипотезы не может.
func TestMeasureТочкаБезЧислаНеСчитается(t *testing.T) {
	t.Parallel()

	pairs := window(5, func(h time.Time) store.HourPair {
		p := pair(h, 1, 1)
		p.Fine = append(p.Fine, store.Point{
			Start: h.Add(time.Minute), End: h.Add(time.Minute),
			Raw: json.RawMessage(`{"source":"часы"}`),
		})
		return p
	})

	_, basis := catalog.Measure(pairs)
	if basis.Compared != 0 {
		t.Errorf("пригодных часов: %d, ждали 0", basis.Compared)
	}
}

// Перевес мгновенной гипотезы над накопительной: разложение основания одно на
// обе ветки, и `agreeing` всегда относится к преобладающему вердикту.
func TestMeasureПротиворечиеСПеревесомМгновенной(t *testing.T) {
	t.Parallel()

	pairs := []store.HourPair{
		pair(hour(4), 2, 1, 2, 3),
		pair(hour(3), 2, 1, 2, 3),
		pair(hour(2), 2, 1, 2, 3),
		pair(hour(1), 2, 1, 2, 3),
		pair(hour(0), 6, 1, 2, 3),
	}

	style, basis := catalog.Measure(pairs)
	if style != catalog.Unknown {
		t.Fatalf("род: получили %v, ждали unknown", style)
	}
	if basis.Agreeing != 4 || basis.Conflicting != 1 {
		t.Errorf("основание: %+v — согласные обязаны быть у преобладающей гипотезы", basis)
	}
}

// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род
// уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же
// `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на
// проводе разошёлся бы с этим молча.
//
// Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен
// видеть состояние, которого в словаре нет.
func TestStyleСловарь(t *testing.T) {
	t.Parallel()

	cases := map[catalog.Style]string{
		catalog.Cumulative: "cumulative",
		catalog.Instant:    "instant",
		catalog.Unknown:    "unknown",
		catalog.Style(42):  "unknown",
	}
	for style, want := range cases {
		if got := style.String(); got != want {
			t.Errorf("род %d: получили %q, ждали %q", style, got, want)
		}
	}
}
