package store

import (
	"context"
	"database/sql"
	"fmt"
	"time"
)

// CategoryValue — строка реестра категориальных значений: перечислимая строка,
// которую приносил поток, и выведенный для неё код HealthKit.
//
// Значение хранится дословно; код — кэш чистой функции от словаря в бинаре, и в
// отпечаток витрины он не входит (см. fingerprintCategories).
type CategoryValue struct {
	// Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица
	// хранения.
	Metric string
	// Field — имя поля внутри точки или сущности, дословно как у HAE.
	Field string
	// Value — строка, как прислал HAE.
	Value string
	// Code — канонический код HealthKit; пустой означает «словарь не знает».
	Code string

	// FirstSeen и FirstDeliveryID — провенанс ПЕРВОЙ встречи по порядку журнала.
	// Заполняются хранилищем, вызывающему при записи не нужны.
	FirstSeen       time.Time
	FirstDeliveryID string
}

// mergeCategories записывает наблюдения доставки в реестр.
//
// Правило записи одно и оно решает всё: код обновляется ВСЕГДА, провенанс —
// только вниз, к более раннему месту в журнале. Иначе повторная свёртка той же
// доставки меняла бы состояние, а проигрывание журнала в порядке `(received_at,
// id)` давало бы не то, что живой приём.
//
// Сравнение провенанса идёт парой `(received_at, id)`, а не одной меткой:
// доставки одной секунды в журнале упорядочены идентификатором, и сравнение по
// одной метке сделало бы исход зависящим от того, какая из них свернулась
// раньше. Пара выражена лексикографически прямо в SQL — переносить сравнение в
// Go значило бы читать строку, решать и писать, то есть добавлять обращение к
// базе на каждое наблюдение внутри транзакции.
func mergeCategories(ctx context.Context, tx *sql.Tx, values []CategoryValue, from DeliveryRef) error {
	if len(values) == 0 {
		return nil
	}

	const q = `
		INSERT INTO category_value
			(metric, field, value, code, first_seen_utc, first_delivery_id)
		VALUES (?, ?, ?, ?, ?, ?)
		ON CONFLICT (metric, field, value) DO UPDATE SET
			code = excluded.code,
			first_seen_utc = CASE
				WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
				   < (category_value.first_seen_utc, category_value.first_delivery_id)
				THEN excluded.first_seen_utc ELSE category_value.first_seen_utc END,
			first_delivery_id = CASE
				WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
				   < (category_value.first_seen_utc, category_value.first_delivery_id)
				THEN excluded.first_delivery_id ELSE category_value.first_delivery_id END`

	stmt, err := tx.PrepareContext(ctx, q)
	if err != nil {
		return fmt.Errorf("prepare category upsert: %w", err)
	}
	defer func() { _ = stmt.Close() }()

	at := FormatTime(from.ReceivedAt)
	for _, v := range values {
		if _, err := stmt.ExecContext(ctx, v.Metric, v.Field, v.Value, v.Code, at, from.ID); err != nil {
			// Значение в текст ошибки не попадает: строка категориального
			// значения — данные о здоровье наравне со значением точки.
			//
			// Координаты обрезаются, хотя сегодня они приходят из объявленного
			// списка полей и произвольной строкой из тела быть не могут. Предел
			// стоит здесь, а не держится на форме того списка: правило вида
			// «поле `value` у метрик с таким-то именем» уронило бы имя метрики
			// из чужого тела прямо в `ERROR`, и заметить это было бы нечем.
			return fmt.Errorf("upsert category value (%s/%s): %w",
				clipCoord(v.Metric), clipCoord(v.Field), err)
		}
	}
	return nil
}

// CategoryValues возвращает реестр целиком в детерминированном порядке.
//
// Реестр мал по построению — на живом потоке различных значений около
// одиннадцати, — поэтому страничного чтения у него нет и заводить его незачем:
// границы разбора не дают доставке положить больше 64 значений, а число полей
// объявлено списком.
func (s *Store) CategoryValues(ctx context.Context) ([]CategoryValue, error) {
	const q = `
		SELECT metric, field, value, code, first_seen_utc, first_delivery_id
		FROM category_value ORDER BY metric, field, value`

	rows, err := s.db.QueryContext(ctx, q)
	if err != nil {
		return nil, fmt.Errorf("select category values: %w", err)
	}
	defer func() { _ = rows.Close() }()

	var out []CategoryValue
	for rows.Next() {
		var v CategoryValue
		var firstSeen string
		if err := rows.Scan(&v.Metric, &v.Field, &v.Value, &v.Code, &firstSeen, &v.FirstDeliveryID); err != nil {
			return nil, fmt.Errorf("scan category value: %w", err)
		}
		v.FirstSeen, err = ParseTime(firstSeen)
		if err != nil {
			return nil, err
		}
		out = append(out, v)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("select category values: %w", err)
	}
	return out, nil
}

// CountCategoryValues возвращает число строк реестра.
//
// Нужно отчёту пересборки: реестр — единица хранения витрины, и без счётчика
// «до и после» расхождение отпечатков по нему безадресно — числа объектов,
// тренировок и записей при этом не меняются. Перечислением отвечать нельзя:
// сами строки — данные о здоровье, а отчёт печатается человеку в терминал.
func (s *Store) CountCategoryValues(ctx context.Context) (int64, error) {
	var n int64
	if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM category_value`); err != nil {
		return 0, fmt.Errorf("count category values: %w", err)
	}
	return n, nil
}

// maxCoordInLog — предел длины координаты наблюдения в тексте ошибки.
//
// Та же граница и по той же причине, что у имени метрики в координатах
// столкновения: значение из тела ничем не ограничено, а предел приёма — 64 МиБ.
const maxCoordInLog = 64

func clipCoord(s string) string {
	if len(s) <= maxCoordInLog {
		return s
	}
	return s[:maxCoordInLog] + "…"
}
