package store

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	"git.vakhrushev.me/av/healthlog/internal/canon"
)

// Роды таблиц сущностей. Тренировка живёт в своей таблице: у неё есть
// заголовок, по которому идёт выборка, а у записи его нет.
const (
	workoutTable = "workout"
	recordTable  = "record"
)

// IncomingEntity — сущность с собственным идентификатором, пришедшая на запись.
//
// Содержимое хранится исходными байтами: сущность, пересобранная повторной
// сериализацией, теряет литерал ровно так же, как точка.
type IncomingEntity struct {
	ID   string
	Kind string
	Name string

	Start time.Time
	End   time.Time
	// OffsetSeconds — смещение зоны начала.
	OffsetSeconds int
	// Duration — длительность тренировки в секундах. nil означает «источник не
	// прислал» и отличим от нуля: ноль — законная длительность.
	Duration *float64

	Raw json.RawMessage
}

// Incoming — всё, что дала одна доставка. Единицей записи является доставка, а
// не точка и не сущность: частичное состояние ломает инвариант «состояние
// пересобираемо».
type Incoming struct {
	Points   []IncomingPoint
	Workouts []IncomingEntity
	Records  []IncomingEntity
	// Categories — наблюдённые категориальные значения доставки. Пишутся той же
	// транзакцией: состояние «точки легли, реестр нет» повторная свёртка не
	// чинит — хеш содержимого сойдётся, и объекты переписываться не станут.
	Categories []CategoryValue
}

// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
// разрешается тай-брейк между версиями сущности равной полноты, а порядок
// журнала задан парой `(received_at, id)`.
type DeliveryRef struct {
	ID         string
	ReceivedAt time.Time
}

func (d DeliveryRef) before(other DeliveryRef) bool {
	if !d.ReceivedAt.Equal(other.ReceivedAt) {
		return d.ReceivedAt.Before(other.ReceivedAt)
	}
	return d.ID < other.ID
}

// EntityRef — координаты сущности для записи в лог. Содержимого не несёт:
// маршрут тренировки — это геотрек до дома, а метки состояния разума —
// измерение душевного состояния.
type EntityRef struct {
	Kind string
	ID   string
}

// maxEntityRefsReported — сколько координат сущностей попадает в лог.
const maxEntityRefsReported = 5

// prepareEntities считает хеш каждой сущности.
//
// Вынесено из транзакции намеренно: хеширование материализует значение целиком
// (маршрут — до мегабайта), а транзакция повторяется до пяти раз при занятости
// базы.
func prepareEntities(in []IncomingEntity, from DeliveryRef) ([]entityVersion, error) {
	if len(in) == 0 {
		return nil, nil
	}
	out := make([]entityVersion, 0, len(in))
	for _, e := range in {
		v, err := newEntityVersion(e, from)
		if err != nil {
			return nil, err
		}
		out = append(out, v)
	}
	return out, nil
}

// clipRefs держит список координат в потолке: он зацепка для разбора, а не
// отчёт; масштаб события считает счётчик.
func clipRefs(refs []EntityRef) []EntityRef {
	if len(refs) <= maxEntityRefsReported {
		return refs
	}
	return refs[:maxEntityRefsReported]
}

// entityVersion — версия сущности вместе с тем, что нужно знать при выборе
// победителя.
//
// Всё считается СРАЗУ и один раз на версию, до входа в транзакцию. Ленивость
// здесь была мнимой: хеш всё равно требует полной канонической формы, то есть
// самая дорогая работа платилась на каждой копии и так, а отложенный разбор
// считал ту же форму ВТОРОЙ раз — и делал это внутри транзакции, которая
// открыта `immediate` и повторяется до пяти раз при занятости базы.
//
// Баланс назван честно: на пути разошедшегося хеша (одна доставка из сорока
// четырёх) стало на одну полную канонизацию меньше; на пути совпавшего хеша
// добавился мелкий разбор в map[string]json.RawMessage — проход по телу без
// разворачивания значений. Внутри транзакции для приехавших версий не остаётся
// ничего.
type entityVersion struct {
	raw    json.RawMessage
	hash   string
	from   DeliveryRef
	key    []byte
	fields canon.Fields

	// head — заголовок, который пишется колонками. У сохранённой версии он не
	// нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
	head IncomingEntity
}

func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
	v, err := analyzeVersion(e.Raw, from)
	if err != nil {
		return entityVersion{}, err
	}
	v.head = e
	return v, nil
}

// newStoredVersion собирает версию, прочитанную из витрины.
//
// Каноническая форма здесь НЕ считается, и это существенно: разбор сохранённой
// версии — единственная работа, которая осталась внутри транзакции, открытой
// `immediate`. Замер на тренировке в 168 КБ: полная канонизация с хешем — 4.5 мс
// и 2.3 МБ на 38 тысячах аллокаций, множества ключей — 1.3 мс и 174 КБ на
// тридцати. Хеш сохранённой уже лежит колонкой, а форма нужна ровно в одной
// ветке тай-брейка (равные позиции журнала — та же доставка, свёрнутая
// повторно) и считается там лениво.
func newStoredVersion(raw json.RawMessage, hash string, from DeliveryRef) entityVersion {
	return entityVersion{
		raw:    raw,
		hash:   hash,
		from:   from,
		fields: canon.Analyze(raw),
	}
}

func analyzeVersion(raw json.RawMessage, from DeliveryRef) (entityVersion, error) {
	form, hash, err := canon.FormAndHash(raw)
	if err != nil {
		return entityVersion{}, fmt.Errorf("канонизация сущности: %w", err)
	}
	return entityVersion{
		raw:    raw,
		hash:   hash,
		from:   from,
		key:    form,
		fields: canon.Analyze(raw),
	}, nil
}

// sortKey отдаёт каноническую форму версии, считая её при необходимости.
//
// Ленивость здесь одна на весь файл и нужна ровно сохранённой версии: у неё
// форма требуется только в тай-брейке равных позиций журнала, а стоит она
// втрое дороже разбора и платится под блокировкой записи.
func (v *entityVersion) sortKey() []byte {
	if v.key == nil {
		v.key = canon.SortKey(v.raw)
	}
	return v.key
}

// pickEntity выбирает между сохранённой и приехавшей версией.
//
// Второй возврат — потеряла ли бы витрина содержание, приняв приехавшую. Это и
// есть плата за отказ объединять поля: событие не предотвращается молча, а
// считается и уходит в WARN.
//
//  1. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая
//  2. сохранённая несёт всё содержание приехавшей и сверх того → сохранённая
//  3. содержание равно → версия из более поздней доставки ЖУРНАЛА
//  4. наборы несравнимы → сохранённая
//
// Пункт 3 — не «побеждает приехавшая», и различие здесь в СИЛЕ гарантии, а не
// в намерении. Порядок свёртки приведён к порядку журнала (барьер на
// отложенной доставке в replay.Worker), но равенство неполное: строка учёта
// становится видимой только после записи тела, и при конкурентном приёме
// доставка с более ранней меткой сворачивается позже. Хранимая позиция журнала
// это окно закрывает целиком, происхождение кандидата — нет. У точек колонки
// провенанса нет и заводить её дорого, поэтому там взято происхождение вместе
// с обеспеченным порядком; здесь колонка есть, и терять из-за неё гарантию
// незачем. Критерий выбора записан в architecture.md, «Разрешение
// столкновений».
//
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
// решает минимум канонической формы, потому что порядок элементов в
// JSON-массиве нестабилен.
func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
	switch v := compareEntities(stored, incoming); v {
	case entityIncomingRicher:
		return true, false
	case entityStoredRicher:
		return false, true
	case entityIncomparable:
		// Несравнимы: у каждой версии есть содержание, которого нет у другой.
		// Объединение полей отвергнуто там же и по той же причине, что для
		// точек, — на живом потоке событие не наступало ни разу, — а из двух
		// версий остаётся сохранённая: правило называется «не теряет
		// содержания», и приехавшая его теряет.
		//
		// Исход здесь зависит от порядка свёртки, а не от журнала: в витрине
		// лежит победитель прошлых слияний, а не все кандидаты истории, и
		// «сохранённая выигрывает» означает разный итог при разном порядке.
		// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше
		// не перегоняется своими преемницами — проход воркера прекращается на
		// ней. Осталось окно конкурентного приёма, где строка учёта становится
		// видимой позже метки; там живой приём и пересборка на несравнимых
		// версиях законно расходятся, и воркер об этом пишет WARN. Счётчик
		// удержаний ниже — второе, что о расхождении сообщает.
		return false, true
	default:
		return laterInJournal(stored, incoming), false
	}
}

// entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
type entityVerdict int

const (
	// entityEqualContent — множества содержательных ключей совпадают, длины
	// верхнеуровневых массивов тоже. Значения при этом могут расходиться: их
	// сравнение здесь неприменимо (см. canon.Fields.Covers).
	entityEqualContent entityVerdict = iota + 1
	entityIncomingRicher
	entityStoredRicher
	entityIncomparable
)

func compareEntities(stored, incoming *entityVersion) entityVerdict {
	storedCovers := stored.fields.Covers(incoming.fields)
	incomingCovers := incoming.fields.Covers(stored.fields)

	switch {
	case incomingCovers && storedCovers:
		return entityEqualContent
	case incomingCovers:
		return entityIncomingRicher
	case storedCovers:
		return entityStoredRicher
	default:
		return entityIncomparable
	}
}

// laterInJournal говорит, стоит ли приехавшая версия позже сохранённой в
// журнале. Позиции равны у двух версий одного ключа внутри одной доставки;
// там решает минимум канонической формы — порядок тотальный и от порядка
// элементов в массиве не зависит.
func laterInJournal(stored, incoming *entityVersion) bool {
	if stored.from.before(incoming.from) {
		return true
	}
	if incoming.from.before(stored.from) {
		return false
	}
	return bytes.Compare(incoming.sortKey(), stored.sortKey()) < 0
}

// entityDominates говорит, СТРОГО ли a превосходит b по содержанию: покрывает и
// не покрывается в ответ.
//
// Строгость обязательна. Covers — предпорядок, а не строгий порядок: две версии
// могут покрывать друг друга взаимно (тот же набор ключей, другие значения), и
// отбрасывание «всего, что кем-то покрыто» опустошило бы множество, потеряв обе.
func entityDominates(a, b entityVersion) bool {
	return a.fields.Covers(b.fields) && !b.fields.Covers(a.fields)
}

// entityLess — тотальный порядок на версиях равного содержания.
//
// Сперва каноническая форма, потом ИСХОДНЫЕ БАЙТЫ. Второй разряд не украшение:
// у сущностей версии с равной формой не схлопываются (в отличие от точек, где
// это делает дедупликация по ключу), а у HAE порядок ключей в JSON и запись
// числа нестабильны — то есть без него минимум неединствен, и в витрину лёг бы
// тот элемент, что стоял в массиве раньше. Порядок элементов на проводе не
// имеет права решать, какие байты хранятся.
func entityLess(a, b entityVersion) bool {
	if c := bytes.Compare(a.key, b.key); c != 0 {
		return c < 0
	}
	return bytes.Compare(a.raw, b.raw) < 0
}

// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки — до сравнения
// с сохранённой.
//
// Победитель здесь — функция МНОЖЕСТВА версий, а не порядка элементов массива:
// сперва отбрасываются строго покрытые, среди оставшихся берётся минимум
// тотального порядка. Попарная свёртка была неверна ровно так же, как она была
// неверна для точек: покрытие — частичный порядок, тай-брейк — тотальный, и
// вместе они дают нетранзитивную победу, при которой [A,B,C] и [B,C,A] дают
// разных победителей.
//
// Версии с СОВПАВШЕЙ канонической формой схлопываются ДО выбора победителя, и
// это не оптимизация ради красоты: выбор квадратичен по числу кандидатов, а их
// число приходит из чужого тела. Точки схлопываются так же и в том же месте
// (см. mergePoints). Внутри схлопнутой группы остаются минимальные байты —
// тот же второй разряд тотального порядка, что и между группами.
//
// Второй возврат — счётчик «в одном теле приехали версии одного ключа с РАЗНЫМ
// содержанием», симметричный по построению: считаются кандидаты, чья форма
// отличается от формы победителя. По форме, а не по байтам: порядок ключей у
// HAE нестабилен и дребезг последнего разряда тоже, так что побайтовый счётчик
// срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
// понадобился бы.
//
// Счётчик отдельный от «удержаний», а не общий с ними. Две версии в одном теле —
// это НЕ потеря содержания: победитель ложится в витрину целиком, и удерживать
// нечего. Смешивать их значило бы отвечать одним числом на два вопроса, которые
// лечатся по-разному, — а на число удержаний опирается единственный контроль
// того, что правило покрытия не стало слишком строгим.
func dedupeEntities(ctx context.Context, versions []entityVersion) ([]entityVersion, int, []EntityRef, error) {
	byKey := make(map[EntityRef][]entityVersion, len(versions))
	order := make([]EntityRef, 0, len(versions))

	for _, v := range versions {
		ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
		if _, seen := byKey[ref]; !seen {
			order = append(order, ref)
		}
		byKey[ref] = append(byKey[ref], v)
	}

	out := make([]entityVersion, 0, len(order))
	diverging := 0
	var divergingAt []EntityRef

	for _, ref := range order {
		cands, dropped := collapseEqualForms(byKey[ref])
		winner, _, err := pickBest(ctx, cands, entityDominates, entityLess)
		if err != nil {
			return nil, 0, nil, err
		}
		out = append(out, cands[winner])

		// Схлопнутые копии победителя различием не считаются: их форма ему
		// равна. Считаются все прочие — и оставшиеся кандидаты, и те, что
		// схлопнулись в них.
		differing := 0
		for i := range cands {
			if i != winner {
				differing += 1 + dropped[i]
			}
		}
		if differing > 0 {
			diverging += differing
			if len(divergingAt) < maxEntityRefsReported {
				divergingAt = append(divergingAt, ref)
			}
		}
	}
	return out, diverging, divergingAt, nil
}

// collapseEqualForms схлопывает версии с одинаковой канонической формой в одну,
// оставляя минимальные исходные байты. Второй возврат — сколько копий сложилось
// в каждого оставшегося кандидата (нужно счётчику различий).
//
// Схлопывание обязательно, а не желательно: без него тело с двадцатью тысячами
// повторов одного `id` даёт четыреста миллионов сравнений покрытия, каждое с
// обходом массивов. Тело в пределах приёма такое вмещает.
func collapseEqualForms(versions []entityVersion) ([]entityVersion, []int) {
	byForm := make(map[string]int, len(versions))
	out := make([]entityVersion, 0, len(versions))
	dropped := make([]int, 0, len(versions))

	for _, v := range versions {
		form := string(v.key)
		i, seen := byForm[form]
		if !seen {
			byForm[form] = len(out)
			out = append(out, v)
			dropped = append(dropped, 0)
			continue
		}
		dropped[i]++
		if bytes.Compare(v.raw, out[i].raw) < 0 {
			// Байты решают внутри группы ровно так же, как между группами:
			// порядок элементов на проводе не имеет права выбирать содержимое.
			// Заголовок едет вместе с байтами — он от них производен.
			out[i] = v
		}
	}
	return out, dropped
}

// mergeEntities сливает сущности одной секции с сохранёнными.
func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []entityVersion, now time.Time) (written, held int, heldAt []EntityRef, err error) {
	for _, v := range versions {
		stored, found, err := readEntityHead(ctx, tx, table, v.head.Kind, v.head.ID)
		if err != nil {
			return 0, 0, nil, err
		}

		if !found {
			if err := writeEntity(ctx, tx, table, v, now); err != nil {
				return 0, 0, nil, err
			}
			written++
			continue
		}

		// Хеш — детектор изменений: совпал, значит содержимое то же, и читать
		// его не приходится вовсе. Тренировка переприсылается каждой доставкой,
		// пока не доедет маршрут, — на живом архиве 44 копии дают три различных
		// содержимых.
		//
		// Но провенанс при этом обновить НАДО. Сохранённая позиция журнала
		// участвует в тай-брейке «содержание равно», и если в ней осталась
		// первая свёрнутая копия вместо победителя журнала, отложенная доставка
		// вернёт витрину к прежнему содержимому — то есть живая витрина
		// разойдётся с пересборкой, молча и в содержимом тренировки.
		//
		// Предел назван вслух: обновляется провенанс, но НЕ байты. При
		// совпавшей канонической форме в витрине остаются байты той доставки,
		// что свернулась первой, — а порядок ключей у HAE нестабилен, значит у
		// живого приёма и пересборки они могут различаться. Отпечаток этого не
		// различает (он считает по канонической форме), содержания не теряется
		// ничего, а переписывать мегабайтный маршрут на каждой из двадцати
		// шести присылок ради выбора между эквивалентными литералами — цена
		// несоразмерная.
		if stored.hash == v.hash {
			if stored.from.before(v.from) {
				if err := touchEntityProvenance(ctx, tx, table, v); err != nil {
					return 0, 0, nil, err
				}
			}
			continue
		}

		storedRaw, err := readEntityPayload(ctx, tx, table, v.head.Kind, v.head.ID)
		if err != nil {
			return 0, 0, nil, err
		}
		prev := newStoredVersion(storedRaw, stored.hash, stored.from)

		takeIncoming, lost := pickEntity(&prev, &v)
		if lost {
			held++
			if len(heldAt) < maxEntityRefsReported {
				heldAt = append(heldAt, EntityRef{Kind: v.head.Kind, ID: v.head.ID})
			}
		}
		if !takeIncoming {
			continue
		}
		if err := writeEntity(ctx, tx, table, v, now); err != nil {
			return 0, 0, nil, err
		}
		written++
	}
	return written, held, heldAt, nil
}

// touchEntityProvenance поднимает провенанс сущности до более поздней доставки
// журнала, не трогая содержимое.
//
// `updated_at` НЕ двигается, и это отдельное решение, а не экономия. Тренировка
// приезжает до двадцати шести раз; бамп метки на каждой сделал бы её меткой
// касания строки, а не изменения содержимого, и потребитель запроса «что
// изменилось с момента X» получил бы двадцать шесть ложных изменений,
// неотличимых от настоящего досчёта. Провенанс несёт собственную метку —
// времени приёма своей доставки, — и для тай-брейка её достаточно.
//
// Счётчик записанных сущностей такое обновление тоже не увеличивает: он считает
// СОДЕРЖИМОЕ витрины, и сравнимость его с прежними замерами важнее учёта
// обновлённой ссылки.
func touchEntityProvenance(ctx context.Context, tx *sql.Tx, table string, v entityVersion) error {
	q := `UPDATE ` + table + ` SET delivery_id = ?, delivery_received_at = ?` + entityWhere(table)

	args := append([]any{v.from.ID, FormatTime(v.from.ReceivedAt)},
		entityKeyArgs(table, v.head.Kind, v.head.ID)...)

	res, err := tx.ExecContext(ctx, q, args...)
	if err != nil {
		return fmt.Errorf("update %s provenance: %w", table, err)
	}
	// Строка гарантированно существует: её заголовок прочитан этой же
	// транзакцией десятью строками выше. Ноль означал бы, что ключ собран не
	// теми колонками, — а провенанс в отпечаток витрины не входит, значит
	// молчаливый промах не поймает ни один оракул сходимости. Соседи по файлу
	// (FinishParse, MarkSealed) проверяют по той же причине.
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("update %s provenance: %w", table, err)
	}
	if n == 0 {
		return fmt.Errorf("update %s provenance: %w", table, ErrNotFound)
	}
	return nil
}

type storedEntityHead struct {
	hash string
	from DeliveryRef
}

func readEntityHead(ctx context.Context, tx *sql.Tx, table, kind, id string) (storedEntityHead, bool, error) {
	q := `SELECT content_hash, delivery_id, delivery_received_at FROM ` + table + entityWhere(table)

	var (
		head       storedEntityHead
		receivedAt string
	)
	row := queryEntity(ctx, tx, q, table, kind, id)
	err := row.Scan(&head.hash, &head.from.ID, &receivedAt)
	if errors.Is(err, sql.ErrNoRows) {
		return storedEntityHead{}, false, nil
	}
	if err != nil {
		return storedEntityHead{}, false, fmt.Errorf("select %s: %w", table, err)
	}
	// Пустую метку не терпим: колонка NOT NULL без умолчания, и пустота здесь
	// означала бы дефект писателя. Молчаливый нулевой момент сделал бы
	// сохранённую версию «самой ранней в журнале», и её затирала бы любая
	// приехавшая — то есть дефект проявился бы потерей данных, а не отказом.
	head.from.ReceivedAt, err = ParseTime(receivedAt)
	if err != nil {
		return storedEntityHead{}, false, err
	}
	return head, true, nil
}

func readEntityPayload(ctx context.Context, tx *sql.Tx, table, kind, id string) (json.RawMessage, error) {
	q := `SELECT payload FROM ` + table + entityWhere(table)

	var payload []byte
	if err := queryEntity(ctx, tx, q, table, kind, id).Scan(&payload); err != nil {
		return nil, fmt.Errorf("select %s payload: %w", table, err)
	}
	raw, err := gunzipBytes(payload)
	if err != nil {
		return nil, err
	}
	return raw, nil
}

// entityWhere и queryEntity держат разницу между таблицами в одном месте:
// у тренировки ключ — `id`, у записи — пара `kind + id`.
func entityWhere(table string) string {
	if table == recordTable {
		return ` WHERE kind = ? AND id = ?`
	}
	return ` WHERE id = ?`
}

// entityKeyArgs — аргументы к entityWhere. Живут рядом с ним намеренно: число
// `?` в тексте и длина этого среза обязаны меняться вместе, а компилятор их
// соответствия не видит. Промах даст ошибку SQLite внутри транзакции слияния,
// то есть на пути, который повторяется до пяти раз и оканчивается `failed` у
// доставки, а не отказом сборки.
func entityKeyArgs(table, kind, id string) []any {
	if table == recordTable {
		return []any{kind, id}
	}
	return []any{id}
}

func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
	return tx.QueryRowContext(ctx, q, entityKeyArgs(table, kind, id)...)
}

func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error {
	payload, err := gzipBytes(v.raw)
	if err != nil {
		return err
	}
	stamp := FormatTime(now)
	received := FormatTime(v.from.ReceivedAt)

	if table == recordTable {
		const q = `
			INSERT INTO record (kind, id, ts_utc, tz_offset, payload, content_hash,
			                    delivery_id, delivery_received_at, created_at, updated_at)
			VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			ON CONFLICT (kind, id) DO UPDATE SET
			    ts_utc = excluded.ts_utc,
			    tz_offset = excluded.tz_offset,
			    payload = excluded.payload,
			    content_hash = excluded.content_hash,
			    delivery_id = excluded.delivery_id,
			    delivery_received_at = excluded.delivery_received_at,
			    updated_at = excluded.updated_at`

		if _, err := tx.ExecContext(ctx, q,
			v.head.Kind, v.head.ID, FormatTime(v.head.Start), v.head.OffsetSeconds,
			payload, v.hash, v.from.ID, received, stamp, stamp); err != nil {
			return fmt.Errorf("upsert record: %w", err)
		}
		return nil
	}

	const q = `
		INSERT INTO workout (id, name, start_utc, end_utc, tz_offset, duration_sec,
		                     payload, content_hash, delivery_id, delivery_received_at,
		                     created_at, updated_at)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON CONFLICT (id) DO UPDATE SET
		    name = excluded.name,
		    start_utc = excluded.start_utc,
		    end_utc = excluded.end_utc,
		    tz_offset = excluded.tz_offset,
		    duration_sec = excluded.duration_sec,
		    payload = excluded.payload,
		    content_hash = excluded.content_hash,
		    delivery_id = excluded.delivery_id,
		    delivery_received_at = excluded.delivery_received_at,
		    updated_at = excluded.updated_at`

	var duration any
	if v.head.Duration != nil {
		duration = *v.head.Duration
	}
	if _, err := tx.ExecContext(ctx, q,
		v.head.ID, v.head.Name, FormatTime(v.head.Start), FormatTime(v.head.End),
		v.head.OffsetSeconds, duration, payload, v.hash, v.from.ID, received,
		stamp, stamp); err != nil {
		return fmt.Errorf("upsert workout: %w", err)
	}
	return nil
}

// Workout — тренировка, прочитанная из витрины. Нужна тестам и будущему
// Read API: содержимое отдаётся целиком, заголовок — из колонок.
type Workout struct {
	ID            string
	Name          string
	Start         time.Time
	End           time.Time
	OffsetSeconds int
	Duration      *float64
	Raw           json.RawMessage
	Delivery      string
}

// Workout читает тренировку по идентификатору.
func (s *Store) Workout(ctx context.Context, id string) (Workout, error) {
	const q = `
		SELECT id, name, start_utc, end_utc, tz_offset, duration_sec, payload, delivery_id
		FROM workout WHERE id = ?`

	var (
		w            Workout
		start, end   string
		duration     sql.NullFloat64
		payload      []byte
		deliveryFrom string
	)
	err := s.db.QueryRowxContext(ctx, q, id).
		Scan(&w.ID, &w.Name, &start, &end, &w.OffsetSeconds, &duration, &payload, &deliveryFrom)
	if errors.Is(err, sql.ErrNoRows) {
		return Workout{}, ErrNotFound
	}
	if err != nil {
		return Workout{}, fmt.Errorf("select workout: %w", err)
	}

	if w.Start, err = ParseTime(start); err != nil {
		return Workout{}, err
	}
	if w.End, err = ParseTime(end); err != nil {
		return Workout{}, err
	}
	if duration.Valid {
		v := duration.Float64
		w.Duration = &v
	}
	if w.Raw, err = gunzipBytes(payload); err != nil {
		return Workout{}, err
	}
	w.Delivery = deliveryFrom
	return w, nil
}

// Record — запись секции с собственным идентификатором.
type Record struct {
	Kind          string
	ID            string
	TS            time.Time
	OffsetSeconds int
	Raw           json.RawMessage
	Delivery      string
}

// Record читает запись по роду и идентификатору.
func (s *Store) Record(ctx context.Context, kind, id string) (Record, error) {
	const q = `
		SELECT kind, id, ts_utc, tz_offset, payload, delivery_id
		FROM record WHERE kind = ? AND id = ?`

	var (
		r            Record
		ts           string
		payload      []byte
		deliveryFrom string
	)
	err := s.db.QueryRowxContext(ctx, q, kind, id).
		Scan(&r.Kind, &r.ID, &ts, &r.OffsetSeconds, &payload, &deliveryFrom)
	if errors.Is(err, sql.ErrNoRows) {
		return Record{}, ErrNotFound
	}
	if err != nil {
		return Record{}, fmt.Errorf("select record: %w", err)
	}

	if r.TS, err = ParseTime(ts); err != nil {
		return Record{}, err
	}
	if r.Raw, err = gunzipBytes(payload); err != nil {
		return Record{}, err
	}
	r.Delivery = deliveryFrom
	return r, nil
}

// CountWorkouts и CountRecords нужны отчёту пересборки: отпечаток отвечает
// «да/нет», а по «да/нет» нельзя судить о направлении расхождения.
func (s *Store) CountWorkouts(ctx context.Context) (int64, error) {
	var n int64
	if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM workout`); err != nil {
		return 0, fmt.Errorf("count workouts: %w", err)
	}
	return n, nil
}

// CountRecords возвращает число записей секций с собственным `id`.
func (s *Store) CountRecords(ctx context.Context) (int64, error) {
	var n int64
	if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM record`); err != nil {
		return 0, fmt.Errorf("count records: %w", err)
	}
	return n, nil
}
