package hae

import (
	"bytes"
	"encoding/json"
	"strconv"
	"time"
)

// maxEntityID — предел длины идентификатора сущности.
//
// `id` приходит из тела, которым отправитель управляет целиком, а уезжает и в
// первичный ключ таблицы, и в записи лога. UUID HealthKit — 36 байт, так что
// запас велик; правило то же, что уже действует для имён непокрытых секций, и
// оно снимает класс, а не случай.
const maxEntityID = 128

// rfc3339Layout — второй формат метки, которым HAE шлёт stateOfMind
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
const rfc3339Layout = time.RFC3339

// softString — строка заголовка, которая переживает значение не того типа.
//
// Значение не того ТИПА стоит одного поля, а не сущности. Правило уже записано
// рядом для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не
// сломанная сущность»); без него `name`, приехавшее числом, уносит тренировку
// вместе с маршрутом — а доставка при этом числится разобранной, и ретеншен
// получает ответ «терять нечего» ровно там, где потеряно 95% содержимого.
//
// Через json.Unmarshaler, а не через разбор ошибки постфактум. Соблазн есть:
// encoding/json при несовпадении типа «skips that field and completes the
// unmarshaling as best it can» и возвращает *UnmarshalTypeError, то есть
// трёхстрочный errors.As выглядел бы равноценным. Он неравноценен — та же
// документация оговаривает, что дозаполнение полей ПОСЛЕ проблемного не
// гарантировано. Разбор, построенный на этом, перестал бы быть функцией тела:
// одна и та же тренировка давала бы разный заголовок в зависимости от порядка
// ключей на проводе, а он у HAE нестабилен.
//
// Различение счётчиков сохраняется само: элемент, который сам не объект, даёт
// ошибку ВЕРХНЕГО уровня и по-прежнему уходит в «не разобралось как объект», а
// не в «нет id».
// Признак `present` отличает «ключа не было» от «ключ был, но строки из него не
// вышло». Различие нужно ровно одному полю — метке начала, — и там оно
// существенно: см. фолбэк `start → date` ниже.
//
// Именно «ключ был», а не «значение не той формы»: `null` тоже даёт пустую
// строку, и без этого различения `{"date":"…","start":null}` уводил бы
// тренировку на момент времени из другого поля — молча и без счётчика.
type softString struct {
	value string
	// present — ключ присутствовал в объекте. UnmarshalJSON зовётся только на
	// присутствующий ключ, поэтому признак взводится безусловно.
	present bool
}

func (s *softString) UnmarshalJSON(raw []byte) error {
	// Приёмник задаётся ЦЕЛИКОМ, а не дописывается. JSON допускает повтор
	// ключа, и encoding/json зовёт UnmarshalJSON на каждое вхождение с
	// семантикой «побеждает последнее» — так работает соседний Duration и весь
	// разбор метрик. Накопленный признак сделал бы разбор функцией не тела, а
	// истории вызовов: `{"start":123,"start":"2025-06-05 …"}` терял бы
	// тренировку с маршрутом при валидной последней метке.
	*s = softString{present: true}

	var v string
	if err := json.Unmarshal(raw, &v); err != nil {
		// Значение не строка — поле считается непрочитанным. Ошибку глушим
		// сознательно: это и есть мягкость, ради которой тип заведён.
		return nil
	}
	s.value = v
	return nil
}

// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
// хранится дословно.
type entityHead struct {
	ID    softString `json:"id"`
	Name  softString `json:"name"`
	Date  softString `json:"date"`
	Start softString `json:"start"`
	End   softString `json:"end"`

	Duration json.RawMessage `json:"duration"`
}

// decodeEntities разбирает элементы одной покрытой секции в сущности.
//
// Пропуск одного элемента не уносит соседей: у каждого класса пропуска свой
// счётчик, тело остаётся в архиве, и доставку вернёт пересборка, когда разбор
// научится понимать пропущенное.
func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
	if len(raws) == 0 {
		return nil
	}

	out := make([]Entity, 0, len(raws))
	for _, raw := range raws {
		// Род элемента проверяется ДО разбора, потому что `json.Unmarshal`
		// «null» в структуру ошибкой не считает (для JSON null это no-op) — и
		// элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся
		// форма СЕКЦИИ диагностировалась бы как сменившаяся форма
		// ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия.
		if !isJSONObject(raw) {
			res.SkippedEntityMalformed++
			continue
		}
		var head entityHead
		if err := json.Unmarshal(raw, &head); err != nil {
			res.SkippedEntityMalformed++
			continue
		}
		// Идентификатор исключение из мягкости: без строкового `id` сущность не
		// адресуема, а приведение чужого нестрокового значения к строке было бы
		// выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение
		// уже превратило в пустую строку — исход тот же, что у отсутствующего.
		id := head.ID.value
		if id == "" || len(id) > maxEntityID {
			res.SkippedNoID++
			continue
		}

		// Фолбэк `start → date` существует для сущностей, у которых ключа
		// `start` НЕТ ВОВСЕ. Если ключ пришёл, но строки из него не вышло
		// (число, объект, `null`), фолбэк не срабатывает: композиция двух
		// правил подставила бы метку ДРУГОГО момента времени — неотличимую от
		// настоящей и ничем не считаемую. Такой `start` считается неразбираемой
		// меткой.
		if head.Start.present && head.Start.value == "" {
			res.SkippedEntityNoTime++
			continue
		}
		start, ok := parseEntityTime(firstNonEmpty(head.Start.value, head.Date.value))
		if !ok {
			res.SkippedEntityNoTime++
			continue
		}

		// Конец, которого нет или который не читается, равен началу. У точки то
		// же вырождение запрещено — там оно схлопнуло бы две записи в одну
		// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
		// Истина при этом остаётся в Raw дословно.
		end := start
		if head.End.value != "" {
			if e, ok := parseEntityTime(head.End.value); ok {
				end = e
			}
		}

		_, offset := start.Zone()
		e := Entity{
			ID:            id,
			Kind:          kind,
			Name:          head.Name.value,
			Start:         start.UTC(),
			End:           end.UTC(),
			OffsetSeconds: offset,
			Duration:      parseDuration(head.Duration),
			Raw:           raw,
		}
		out = append(out, e)
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// parseEntityTime разбирает метку сущности, принимая оба измеренных формата.
//
// Оба, а не приписанный секции: формы однозначны и не пересекаются (RFC 3339
// несёт `T` и `Z`), а HAE выравнивает секции между собой по ходу своих
// обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые
// секции шлют переводы. Приписанный секции формат ломался бы молча в день
// такого выравнивания.
//
// Метка ТОЧКИ остаётся строгой (parseTime), и асимметрия намеренная: по метке
// точки выводится слой, причём по метке в исходной зоне. Терпимость там
// означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка
// складывается с минутной; у сущности слоя нет, и терять на строгости нечего.
func parseEntityTime(s string) (time.Time, bool) {
	if s == "" {
		return time.Time{}, false
	}
	if t, err := time.Parse(timeLayout, s); err == nil {
		return t, true
	}
	if t, err := time.Parse(rfc3339Layout, s); err == nil {
		return t, true
	}
	return time.Time{}, false
}

// parseDuration переводит длительность в секунды.
//
// Отсутствие и нечисловое значение дают nil, а не ноль: ноль — законная
// длительность, и потребитель, сложивший столбец, иначе не отличил бы
// «источник не прислал» от «измерено ноль». Вычислять длительность из
// интервала нельзя: HAE шлёт 91.746 при интервале в 91 секунду.
func parseDuration(raw json.RawMessage) *float64 {
	lit := bytes.TrimSpace(raw)
	if len(lit) == 0 {
		return nil
	}
	v, err := strconv.ParseFloat(string(lit), 64)
	if err != nil {
		return nil
	}
	return &v
}

// isJSONObject говорит, является ли значение объектом JSON, по первому байту
// литерала — так же, как это делает сканер encoding/json.
func isJSONObject(raw json.RawMessage) bool {
	lit := bytes.TrimSpace(raw)
	return len(lit) > 0 && lit[0] == '{'
}

func firstNonEmpty(a, b string) string {
	if a != "" {
		return a
	}
	return b
}
