// Package canon — каноническая форма содержимого точки: то, по чему точки
// сравниваются и хешируются.
//
// Пакет общий для разбора и хранения намеренно. Слияние точек живёт в store,
// хеш пересчитывается там же, а сравнивать приходится то, что приехало из hae.
// Положи канонизацию в hae — store станет знать про формат HAE; положи в store
// — импорт родного экспорта Apple потребует второй реализации. Две реализации
// разошлись бы на дребезге последнего разряда, и хеш-детектор превратился бы в
// перезапись недели каждым глубоким проходом синхронизации.
//
// Каноническая форма существует только в момент сравнения. Хранится всегда
// исходные байты точки: обход через разобранные значения теряет литерал
// (`1.0` становится `1`, целые больше 2^53 сдвигаются, невалидный UTF-8
// заменяется на U+FFFD), и потеря не видна тестам на фикстурах — они
// сравнивают разобранное с разобранным.
package canon

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"math"
	"sort"
	"strconv"
)

// SignificantDigits — до скольки значащих цифр округляется число в
// канонической форме.
//
// Без округления сравнение бесполезно: 45 507 из 71 730 повторно приехавших
// точек различались последним разрядом double при одинаковом измерении — 63%
// повторов выглядели новыми (docs/research/apple-health.md, находка 30). Двенадцать
// цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple
// реально измеряет: даже доли процента у walking_asymmetry_percentage не
// доходят до седьмой значащей цифры.
const SignificantDigits = 12

// Form возвращает каноническую форму значения: ключи объектов отсортированы,
// числа округлены до SignificantDigits значащих цифр.
//
// Форма предназначена для сравнения и хеширования, а не для хранения.
//
// Возвращаемый срез принадлежит вызывающему целиком: буфер, в котором форма
// собрана, наружу больше не показывается.
func Form(raw []byte) ([]byte, error) {
	return form(raw)
}

// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
//
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
// проход переприсылает неделю, но почти все сравнения сходятся.
func Hash(raw []byte) (string, error) {
	f, err := form(raw)
	if err != nil {
		return "", err
	}
	return hashOf(f), nil
}

// FormAndHash отдаёт каноническую форму и её хеш ЗА ОДИН проход.
//
// Нужен тем, кому требуется и то, и другое: сущность хешируется ради
// хеш-детектора и канонизируется ради сравнения полноты, и считать форму дважды
// над теми же байтами значит платить дважды за самую дорогую операцию
// хранилища (тело 40 МиБ даёт пик кучи 768 МиБ).
//
// Отдельной функции «хеш по готовой форме» здесь нет намеренно: она вводила бы
// контракт очерёдности, в котором передача сырых байт вместо формы даёт
// правдоподобный, но неверный хеш, а компилятор такую подмену не ловит.
//
// Обратной ошибки — «Form считает хеш и выбрасывает» — здесь тоже нет: общий
// низ у трёх функций один и хеша не считает. Иначе каждая точка при каждом
// слиянии платила бы SHA-256, который никто не смотрит: Form зовётся из SortKey
// на каждый кандидат координаты, из HashAll на каждую точку часа и дважды на
// каждое сравнение в Equal.
func FormAndHash(raw []byte) ([]byte, string, error) {
	f, err := form(raw)
	if err != nil {
		return nil, "", err
	}
	return f, hashOf(f), nil
}

// form — общий низ: каноническая форма и ничего сверх неё.
func form(raw []byte) ([]byte, error) {
	v, err := decode(raw)
	if err != nil {
		return nil, err
	}

	var buf bytes.Buffer
	if err := write(&buf, v); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

func hashOf(form []byte) string {
	sum := sha256.Sum256(form)
	return hex.EncodeToString(sum[:])
}

// HashAll возвращает хеш канонической формы последовательности значений —
// содержимого часового объекта целиком.
func HashAll(raws [][]byte) (string, error) {
	h := sha256.New()
	for _, raw := range raws {
		form, err := Form(raw)
		if err != nil {
			return "", err
		}
		// Разделитель нужен, чтобы склейка соседних значений не давала тот же
		// хеш, что другое их разбиение.
		_, _ = h.Write(form)
		_, _ = h.Write([]byte{0})
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// Equal говорит, одинаковы ли значения с точностью до канонической формы:
// порядка ключей и дребезга последнего разряда.
//
// Именно это, а не побайтовое равенство, является отношением «то же самое» в
// хранилище. Байты нестабильны: порядок ключей в JSON от HAE меняется между
// доставками, а числа расходятся последним разрядом double при одинаковом
// измерении.
func Equal(a, b []byte) bool {
	if bytes.Equal(a, b) {
		return true
	}
	fa, err := Form(a)
	if err != nil {
		return false
	}
	fb, err := Form(b)
	if err != nil {
		return false
	}
	return bytes.Equal(fa, fb)
}

// Fullness — отношение полноты двух точек: несёт ли одна всё, что несёт
// другая, и сверх того.
//
// Полнота — частичный порядок, а не число. Счётчик значащих полей сравним
// всегда и потому отвечает там, где ответа нет: точка с пятью полями без
// содержания «полнее» настоящего измерения с двумя. Измерено (находка 49):
// настоящих столкновений 0.65% координат, из них 981 различаются набором
// полей — это и есть область правила, — а несравнимых наборов ноль.
//
// Нумерация с единицы: нулевое значение не означает ничего. Незаполненное поле
// или ранний возврат не должны выглядеть как «множества равны» — это
// сегодняшний исход по умолчанию, и отказ маскировался бы под успех.
type Fullness int

const (
	// FullnessEqual — множества ключей совпадают.
	FullnessEqual Fullness = iota + 1
	// FullnessSuperset — a несёт всё, что b, и сверх того.
	FullnessSuperset
	// FullnessSubset — b несёт всё, что a, и сверх того.
	FullnessSubset
	// FullnessIncomparable — у каждой точки есть ключ, которого нет у другой.
	FullnessIncomparable
)

func (f Fullness) String() string {
	switch f {
	case FullnessEqual:
		return "equal"
	case FullnessSuperset:
		return "superset"
	case FullnessSubset:
		return "subset"
	case FullnessIncomparable:
		return "incomparable"
	default:
		return "unknown(" + strconv.Itoa(int(f)) + ")"
	}
}

// Fields — ключи точки, разобранные ОДИН раз: множество всех и значения тех,
// что несут содержание.
//
// Разбор вынесен в отдельный тип не ради красоты. Победитель столкновения
// выбирается из множества кандидатов, а не парой (см. store), и сравнений там
// квадратично по числу кандидатов. Разбирай их RelateFullness каждый раз —
// доставка с сотней точек на одной координате разобрала бы каждую сотню раз.
type Fields struct {
	// full — ключ с непустым значением → его исходные байты. Значения нужны
	// целиком: полнота требует не только наличия ключа, но и совпадения
	// содержания (см. Relate).
	full map[string]json.RawMessage
	// all — все ключи, включая те, чьё значение пусто.
	all map[string]struct{}
}

// Analyze разбирает точку на множества ключей.
//
// Поле source не входит ни в одно из множеств: оно нестабильно и
// переписывается задним числом (находка 36), так что о полноте измерения
// ничего не говорит.
//
// Содержимое, которое не разбирается как JSON-объект, даёт пустые множества:
// так оно проигрывает любой точке с содержанием и не загрязняет наблюдение о
// несравнимых наборах.
func Analyze(raw []byte) Fields {
	f := Fields{
		full: make(map[string]json.RawMessage),
		all:  make(map[string]struct{}),
	}

	var obj map[string]json.RawMessage
	if err := json.Unmarshal(raw, &obj); err != nil {
		return f
	}

	for k, v := range obj {
		if k == "source" {
			continue
		}
		f.all[k] = struct{}{}
		if !isEmpty(v) {
			f.full[k] = v
		}
	}
	return f
}

// RelateFullness сравнивает полноту двух точек.
//
// Обёртка над Analyze и Relate для одиночного сравнения; в слиянии разбор
// переиспользуется.
func RelateFullness(a, b []byte) Fullness {
	return Analyze(a).Relate(Analyze(b))
}

// Relate сравнивает полноту: несёт ли одна точка всё, что несёт другая, и
// сверх того.
//
// Разрядов сравнения два. Сперва ключи с НЕПУСТЫМ значением: точка с
// `context: null` не полнее точки без `context`. Если они совпали — все ключи:
// иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
// `Max` исчезли бы из витрины по жребию тай-брейка.
//
// «Несёт всё, что несёт другая» — про СОДЕРЖАНИЕ, а не про имена ключей. Если
// значения общих содержательных ключей разошлись, точки несут разные
// измерения, и надмножество имён о полноте не говорит ничего: иначе
// `{qty: 0.001, p1: null, p2: null}` оказывалось бы полнее `{qty: 72.5}` и
// стирало настоящее измерение — ровно то, ради отрицания чего правило и
// переписано. Такая пара уходит в тай-брейк как равнополная.
//
// Несравнимость — исход ТОЛЬКО первого разряда: у каждой точки есть
// содержательный ключ, которого нет у другой, и объединять там было бы что.
// Во втором разряде лишние ключи заведомо пусты, объединять в них нечего, и
// счётчик, ради которого объединение полей отложено, не должен считать это
// событие (иначе замер «0 из 2 897», снятый по содержательным ключам, теряет
// сопоставимость с тем, что считает код).
//
// Отношение — частичный порядок: антисимметрично по построению и транзитивно
// (если A ⊇ B и B ⊇ C, то ключи C ⊆ ключей B ⊆ ключей A, а согласие значений
// переносится через B). На это опирается выбор победителя из множества.
func (f Fields) Relate(g Fields) Fullness {
	rel := relateKeys(keysOf(f.full), keysOf(g.full))
	if rel == FullnessIncomparable {
		return rel
	}
	if !agreeOnShared(f.full, g.full) {
		return FullnessEqual
	}
	if rel != FullnessEqual {
		return rel
	}

	if rel2 := relateKeys(f.all, g.all); rel2 == FullnessSuperset || rel2 == FullnessSubset {
		return rel2
	}
	return FullnessEqual
}

// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g.
//
// Отдельно от Relate, и это не дубль. Relate гасит отношение включения до
// FullnessEqual, когда значения общих содержательных ключей разошлись, — верно
// для точки (надмножество имён при других значениях означает другое
// измерение), но неверно для сущности с собственным `id`: у неё вторая версия
// есть тот же объект, пересчитанный источником, и значения между версиями
// расходятся ВСЕГДА. Приложи Relate к тренировке — и обеднённая версия
// получила бы «равенство» и заместила бы сохранённую вместе с маршрутом
// (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы
// зелёным.
//
// Условий четыре, все по ВЕРХНЕМУ уровню:
//
//  1. каждый содержательный ключ g есть у f и содержателен;
//
//  2. если множества содержательных ключей СОВПАЛИ — каждый ключ g, даже
//     пустой, есть у f. Тот же второй разряд, что у Relate, и с тем же
//     условием: иначе ключ с пустым значением исчезает по жребию тай-брейка.
//
//     Условность разряда проверена оракулом, а не выведена. Безусловный
//     вариант («строже — значит правильнее») оказался хуже: версия с
//     `totalEnergy: null` и без маршрута запирала законный досчёт навсегда —
//     приехавшая теряла пустой ключ, сохранённая теряла содержательный
//     `route`, и пара становилась несравнимой. Маршрут не доезжал НИКОГДА, и
//     пересборка проигрывала то же поражение. Второй разряд разрешает спор
//     равных, а не отменяет первый;
//
//  3. форма значения не вырождается: где у g объект — у f объект, где массив —
//     массив. Без этого «скелет» (каждый вложенный объект заменён числом)
//     признаётся равным настоящей тренировке и выигрывает тай-брейк журнала;
//
//  4. верхнеуровневый массив не теряет ни длины, ни СОДЕРЖАТЕЛЬНЫХ элементов:
//     усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
//     [null,null,null] не теряет и длины. Досчёт ряды удлиняет, поэтому и
//     укорачивание, и опустошение элементов — законные признаки «приехало
//     меньше».
//
// Условия 3 и 4 применяются к ключам, содержательным у g: у пустоты формы нет,
// и требовать её сохранения значило бы отличать `[]` от `0` там, где ни то, ни
// другое ничего не несёт.
//
// Содержательность элемента ряда — ТА ЖЕ пустота, что у поля (isEmpty): второй
// словарь пустоты дал бы два ответа на один вопрос. Цена названа вслух: ряд
// настоящих нулей ([0,0,0]) считается лишённым содержания, поэтому версия с ним
// сохранённую не заместит. Ошибка направлена в безопасную сторону — правило
// удерживает, а не затирает, и событие видно счётчиком; наблюдённые ряды HAE
// состоят из объектов.
//
// Предел правила назван вслух и не закрывается: сокращение ВНУТРИ элемента ряда
// (точка маршрута без altitude при непустом элементе и той же длине) не ловится
// ничем, кроме сверки с телом в архиве. Поэлементная сверка содержимого
// отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево
// значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика.
//
// Поле `source` в множества не входит (см. Analyze) — исключение придумано для
// точек, где оно измерено, и наследуется сущностью молча. Названо здесь потому,
// что список исключений живёт в Analyze: правка ради точек изменит и правило
// удержания сущностей, а ни один тест сущностей этого не заметит.
//
// Формы и длины считаются здесь, а не в Analyze: Analyze зовётся на каждый
// кандидат слияния точек, и разбор heartbeatSeries на каждой точке стоил бы
// дороже самого сравнения.
func (f Fields) Covers(g Fields) bool {
	for k, gv := range g.full {
		fv, ok := f.full[k]
		if !ok {
			return false
		}
		if !shapeKept(fv, gv) {
			return false
		}
	}
	// Первый разряд пройден. Второй включается ТОЛЬКО при равенстве множеств
	// содержательных ключей: если f несёт содержание сверх g, спор уже решён в
	// её пользу, и пустой ключ его не отменяет.
	if len(f.full) != len(g.full) {
		return true
	}
	for k := range g.all {
		if _, ok := f.all[k]; !ok {
			return false
		}
	}
	return true
}

// shapeKept говорит, сохраняет ли значение fv форму и наполнение gv.
func shapeKept(fv, gv json.RawMessage) bool {
	switch literalKind(gv) {
	case kindObject:
		return literalKind(fv) == kindObject
	case kindArray:
		// Третий возврат смотрится У ОБЕИХ сторон. Неразобравшийся массив у g
		// дал бы нули, то есть покрывался бы даже пустым `[]`. Из тела HAE это
		// недостижимо (значения приходят разобранным JSON), но сохранённая
		// версия приезжает сюда из `payload` базы, а вторым источником сущностей
		// планируется импорт родного экспорта Apple — там байты формирует другой
		// код.
		gTotal, gFull, gok := arrayShape(gv)
		fTotal, fFull, fok := arrayShape(fv)
		return gok && fok && fTotal >= gTotal && fFull >= gFull
	default:
		// Скаляр покрывается чем угодно: у f может быть и объект — это форма
		// богаче, а не беднее.
		return true
	}
}

// literalKind — род значения по первому байту литерала, как это делает сам
// сканер encoding/json. Материализовать значение ради рода незачем.
type literalKindT int

const (
	kindScalar literalKindT = iota
	kindObject
	kindArray
)

func literalKind(raw json.RawMessage) literalKindT {
	lit := bytes.TrimSpace(raw)
	if len(lit) == 0 {
		return kindScalar
	}
	switch lit[0] {
	case '{':
		return kindObject
	case '[':
		return kindArray
	default:
		return kindScalar
	}
}

// arrayShape возвращает число элементов верхнеуровневого массива и число
// СОДЕРЖАТЕЛЬНЫХ среди них. Третий возврат — является ли значение массивом.
//
// Элементы проглатываются в выбрасываемый RawMessage: материализация маршрута в
// дерево значений стоила бы того же, от чего отказался разбор тела. Проверка
// пустоты идёт по литералу элемента и обхода не добавляет — он уже здесь был
// ради счёта.
func arrayShape(raw json.RawMessage) (total, contentful int, ok bool) {
	if literalKind(raw) != kindArray {
		return 0, 0, false
	}

	dec := json.NewDecoder(bytes.NewReader(raw))
	if _, err := dec.Token(); err != nil { // открывающая скобка
		return 0, 0, false
	}
	// Буфер объявлен НАД циклом: RawMessage.UnmarshalJSON делает
	// `append((*m)[0:0], data...)`, то есть переиспользует ёмкость. Объявление
	// внутри цикла обнуляло бы срез каждый виток и давало аллокацию на элемент —
	// маршрут в 593 точки стоил бы 593 аллокаций на каждую проверку покрытия,
	// притом что комментарий выше обещает обратное.
	var elem json.RawMessage
	for dec.More() {
		if err := dec.Decode(&elem); err != nil {
			return 0, 0, false
		}
		total++
		if !isEmpty(elem) {
			contentful++
		}
	}
	return total, contentful, true
}

// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
// расхождением не считаются.
func agreeOnShared(a, b map[string]json.RawMessage) bool {
	for k, va := range a {
		vb, ok := b[k]
		if !ok {
			continue
		}
		if !Equal(va, vb) {
			return false
		}
	}
	return true
}

func keysOf(m map[string]json.RawMessage) map[string]struct{} {
	out := make(map[string]struct{}, len(m))
	for k := range m {
		out[k] = struct{}{}
	}
	return out
}

// CarriesKeyAbsentIn отвечает, есть ли у f ключ с НЕПУСТЫМ значением, которого
// нет у g. Значения при этом не сравниваются вовсе.
//
// Заведено ради наблюдения, которого у правила слияния не было: разряд полноты
// гаснет, когда значения общих содержательных ключей разошлись (см. Relate), и
// тогда исход решает тай-брейк — а он может отдать победу точке, у которой
// содержательного ключа нет. Событие редкое (на живом корпусе 2 координаты из
// 80 129 спорных, обе несравнимые), но это единственное направление, в котором
// новое правило способно потерять содержание, и молчать о нём нельзя.
//
// Отдельным методом, а не через Relate: Relate отвечает про СОДЕРЖАНИЕ (с
// условием совпадения значений), здесь же нужен вопрос про имена, и смешение
// этих двух вопросов однажды уже дало правило, которое считало пустое поле
// содержанием.
func (f Fields) CarriesKeyAbsentIn(g Fields) bool {
	return hasExtra(keysOf(f.full), keysOf(g.full))
}

// relateKeys сравнивает два множества ключей по включению.
func relateKeys(a, b map[string]struct{}) Fullness {
	aExtra := hasExtra(a, b)
	bExtra := hasExtra(b, a)

	switch {
	case aExtra && bExtra:
		return FullnessIncomparable
	case aExtra:
		return FullnessSuperset
	case bExtra:
		return FullnessSubset
	default:
		return FullnessEqual
	}
}

// hasExtra говорит, есть ли в a ключ, которого нет в b.
func hasExtra(a, b map[string]struct{}) bool {
	for k := range a {
		if _, ok := b[k]; !ok {
			return true
		}
	}
	return false
}

// Less задаёт детерминированный порядок на точках равной полноты.
//
// Тай-брейк по времени приёма для этого не годится: у сохранённой точки нет
// провенанса, сравнивать не с чем, а четверть доставок несёт столкновения
// ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит
// только от самих значений, поэтому свёртка по журналу даёт то же состояние,
// что приём в реальном времени.
//
// Порядок ТОТАЛЬНЫЙ, включая вход, который не канонизируется: иначе на паре
// из двух неразбираемых значений Less(a,b) и Less(b,a) оба давали бы false,
// победителем оказывался бы просто второй аргумент, и пересборка журнала
// разошлась бы с живым приёмом. Сегодня такой вход недостижим — hae отсеивает
// точки, не разбирающиеся в объект, — но станет достижимым со вторым
// источником точек (импорт родного экспорта Apple).
func Less(a, b []byte) bool {
	return bytes.Compare(SortKey(a), SortKey(b)) < 0
}

// SortKey возвращает то, по чему точки упорядочиваются: каноническую форму,
// а для содержимого, которое не канонизируется, — исходные байты.
//
// Вынесено наружу, чтобы слияние считало форму один раз на точку, а не по разу
// на каждое сравнение.
func SortKey(raw []byte) []byte {
	form, err := Form(raw)
	if err != nil {
		return raw
	}
	return form
}

// isEmpty говорит, несёт ли поле содержание.
//
// Пусто — `null`, пустая строка, число, равное нулю, пустой объект и пустой
// массив. Набор совпадает с `omitempty` из encoding/json минус `false` плюс
// пустой объект, и оба отклонения сознательны:
//
//   - `false` пустотой НЕ считается: для булева поля это одно из двух значений,
//     а не отсутствие сведений (`isIndoor: false` — тренировка на улице).
//   - Ноль считается: точка, где все значения нулевые, не должна вытеснять
//     настоящее измерение. Цена названа вслух — при столкновении нулевого
//     значения с ненулевым по одним координатам выиграет ненулевое, хотя ноль
//     бывает и настоящим измерением. Речь именно о столкновении, где одно из
//     двух содержимых заведомо неверно; одиночная нулевая точка хранится как
//     пришла.
//
// Значение НЕ материализуется: решение принимается по литералу. Разбор
// значения целиком стоил бы разворачивания heartbeatSeries в []any на каждое
// сравнение — ровно той формы, от которой разбор тела намеренно отказался
// (197 МиБ кучи против 54 МиБ на теле 42 МиБ). При этом `0.0`, `0e0`, `-0` и
// `{ }` обязаны считаться пустыми, поэтому по байтам сравнивать тоже нельзя:
// число проверяется strconv, скобки — на пробельное содержимое.
func isEmpty(v json.RawMessage) bool {
	lit := bytes.TrimSpace(v)
	if len(lit) == 0 {
		return true
	}

	switch lit[0] {
	case 'n': // null
		return bytes.Equal(lit, []byte("null"))
	case '"':
		return bytes.Equal(lit, []byte(`""`))
	case '{':
		return emptyBracketed(lit, '{', '}')
	case '[':
		return emptyBracketed(lit, '[', ']')
	case 't', 'f':
		// Булево — одно из двух значений, а не отсутствие сведений.
		return false
	default:
		f, err := strconv.ParseFloat(string(lit), 64)
		return err == nil && f == 0
	}
}

// emptyBracketed говорит, что между скобками нет ничего, кроме пробелов.
func emptyBracketed(lit []byte, open, close byte) bool {
	if len(lit) < 2 || lit[0] != open || lit[len(lit)-1] != close {
		return false
	}
	return len(bytes.TrimSpace(lit[1:len(lit)-1])) == 0
}

// decode разбирает значение с числами в виде json.Number: строковый литерал
// вместо float64. Без этого округление применялось бы к уже испорченному
// значению — round-trip через float64 сам по себе меняет литерал.
func decode(raw []byte) (any, error) {
	dec := json.NewDecoder(bytes.NewReader(raw))
	dec.UseNumber()

	var v any
	if err := dec.Decode(&v); err != nil {
		return nil, fmt.Errorf("canon: разбор значения: %w", err)
	}
	return v, nil
}

// write пишет каноническую форму значения.
//
// Сортировку ключей объекта делает encoding/json сам (json.Marshal для map
// сортирует ключи), но здесь она выполняется явно: значения приходится
// обходить всё равно — ради чисел, — и второй проход через json.Marshal
// означал бы round-trip числа через float64.
func write(buf *bytes.Buffer, v any) error {
	switch t := v.(type) {
	case map[string]any:
		keys := make([]string, 0, len(t))
		for k := range t {
			keys = append(keys, k)
		}
		sort.Strings(keys)

		buf.WriteByte('{')
		for i, k := range keys {
			if i > 0 {
				buf.WriteByte(',')
			}
			key, err := json.Marshal(k)
			if err != nil {
				return fmt.Errorf("canon: ключ %q: %w", k, err)
			}
			buf.Write(key)
			buf.WriteByte(':')
			if err := write(buf, t[k]); err != nil {
				return err
			}
		}
		buf.WriteByte('}')

	case []any:
		buf.WriteByte('[')
		for i, e := range t {
			if i > 0 {
				buf.WriteByte(',')
			}
			if err := write(buf, e); err != nil {
				return err
			}
		}
		buf.WriteByte(']')

	case json.Number:
		buf.WriteString(roundNumber(t.String()))

	default:
		// Строки, bool и null: json.Marshal даёт для них ту же форму, что
		// пришла, и своей реализации не требует.
		b, err := json.Marshal(t)
		if err != nil {
			return fmt.Errorf("canon: значение: %w", err)
		}
		buf.Write(b)
	}
	return nil
}

// roundNumber округляет числовой литерал до SignificantDigits значащих цифр.
//
// Целые остаются как есть: у них дребезга сериализации не бывает, а округление
// сдвинуло бы большие идентификаторы. Литерал, не разбирающийся как число,
// возвращается дословно — канонизация не место, где решается судьба
// непонятного входа.
func roundNumber(lit string) string {
	if !bytes.ContainsAny([]byte(lit), ".eE") {
		return lit
	}

	f, err := strconv.ParseFloat(lit, 64)
	if err != nil {
		return lit
	}
	if math.IsInf(f, 0) || math.IsNaN(f) {
		return lit
	}

	// %g с точностью в значащих цифрах — ровно то, что нужно: экспонента
	// выбирается сама, хвост за пределами точности отбрасывается.
	return strconv.FormatFloat(f, 'g', SignificantDigits, 64)
}
