// Package healthkit — знание о значениях HealthKit: словарь локализованных
// строк и эквивалентность имён кодов между версиями iOS.
//
// Отдельно от разбора HAE потому, что источников у этого знания будет два.
// HAE отдаёт перечислимые значения строками локали телефона («БДГ», «Сидячий
// образ жизни»), а родной экспорт Apple — кодами (находка 37); словарь нужен
// первому, таблица синонимов — обоим. Пакет не зависит ни от чего внутреннего и
// ничего не пишет: обе операции — чистые функции.
//
// Чего здесь нет намеренно: знания о том, КАКИЕ поля HAE несут категориальные
// значения. Имена `value`, `context`, `name` принадлежат формату HAE и живут в
// internal/hae — иначе импорт родного экспорта потянул бы за собой словарь имён
// полей чужого приложения.
package healthkit

import "strings"

// Префикс кодов фазы сна. Вынесен ради читаемости таблицы: без него шесть строк
// словаря отличаются друг от друга последним словом в конце длинной строки.
const sleepPrefix = "HKCategoryValueSleepAnalysis"

// dictionary — локализованная строка → код HealthKit, по локалям.
//
// Словарь не составлен, а ВЫВЕДЕН: сопоставлением потока HAE с родным экспортом
// Apple за тот же период (docs/research/apple-health.md, находка 43). Числа
// вхождений на живом корпусе — 692 «Основная», 568 «Бодрствование», 206 «БДГ»,
// 171 «Во сне», 94 «Глубокий», 38 «В кровати» — сошлись с фазами экспорта
// однозначно.
//
// Локаль одна, `ru`: другого языка телефон не присылал ни разу. Строка
// незнакомой локали получает код вторым разрядом Code, если сама строка
// однозначна, — так что вторая локаль добавляется одной записью и ничего не
// ломает.
//
// Контекста пульса и типов тренировок здесь нет, и это не пробел, а отказ
// угадывать: экспорт Apple хранит контекст пульса метаданным-числом, а не
// `HKCategoryValue*`, и сопоставление «Сидячий образ жизни» с чем бы то ни было
// осталось бы догадкой. Их строки видны реестром с пустым кодом — это и есть
// заявка на будущий вывод.
var dictionary = map[string]map[string]string{
	"ru": {
		"Основная":      sleepPrefix + "AsleepCore",
		"Бодрствование": sleepPrefix + "Awake",
		"БДГ":           sleepPrefix + "AsleepREM",
		"Глубокий":      sleepPrefix + "AsleepDeep",
		"В кровати":     sleepPrefix + "InBed",
		"Во сне":        sleepPrefix + "AsleepUnspecified",
	},
}

// synonyms — устаревшее имя кода → нынешнее.
//
// Коды HealthKit устойчивее локализованных строк, но не вечны: те же 338
// записей сна экспортированы как `…Asleep` в 2021 году и как
// `…AsleepUnspecified` в 2026-м, причём счётчики сошлись до единицы — Apple
// переименовала значение и переписывает историю при выгрузке (находка 43). Без
// этой таблицы история раскололась бы вторично, уже на «стабильной» стороне.
//
// Таблица ПЛОСКАЯ: ни одно её значение не является ключом, поэтому алиас
// разрешается ровно за один шаг. Инвариант держит тест по таблице целиком, а не
// обход цепочек в рантайме: у обхода нет ни одного достижимого сценария, зато
// есть собственный вырожденный случай — «что вернуть при превышении глубины».
var synonyms = map[string]string{
	sleepPrefix + "Asleep": sleepPrefix + "AsleepUnspecified",
}

// byValue — строка → код, когда локаль неизвестна.
//
// Пустой код означает «строка встречается в разных локалях с разными кодами» и
// от «строки нет вовсе» на выходе Code не отличается: оба исхода означают «не
// угадываем». Различать их незачем — решение одно.
//
// Индекс существует не ради удобства. Заголовки запроса в сыром архиве не
// лежат, поэтому доставка, восстановленная из осиротевшего тела, приезжает без
// `Accept-Language`; правило «нет локали — нет кода» сделало бы состояние
// функцией от того, уцелела ли учётная строка, то есть сломало бы
// `import + replay`.
var byValue = buildByValue()

func buildByValue() map[string]string {
	out := make(map[string]string)
	for _, values := range dictionary {
		for value, code := range values {
			code = Canonical(code)
			if prev, seen := out[value]; seen && prev != code {
				// Расхождение локалей: выбирать не из чего.
				out[value] = ""
				continue
			}
			out[value] = code
		}
	}
	return out
}

// Code возвращает канонический код HealthKit для локализованной строки.
//
// Три разряда, и второй обязателен, а не удобен (см. byValue):
//
//  1. пара (локаль, строка) есть в словаре — её код;
//  2. локали нет либо пары нет, но строка однозначна по всем локалям — её код;
//  3. иначе — пустой код.
//
// Пустой код честнее догадки: по коду сверяются с экспортом Apple, а неверный
// код неотличим от верного до тех пор, пока по нему не примут решение.
func Code(locale, value string) string {
	if value == "" {
		return ""
	}
	if values, ok := dictionary[locale]; ok {
		if code, ok := values[value]; ok {
			return Canonical(code)
		}
	}
	return byValue[value]
}

// Canonical приводит устаревшее имя кода к нынешнему.
//
// Зовётся и изнутри Code, поэтому «две формы сходятся в один код» верно по
// построению, а не по дисциплине того, кто правит словарь.
func Canonical(code string) string {
	if to, ok := synonyms[code]; ok {
		return to
	}
	return code
}

// maxLocaleTag — предел длины языкового тега.
//
// Первичный подтег BCP 47 — от двух до восьми букв; предел стоит на всём теге
// до отсечения подтегов, с запасом. Заголовок контролирует отправитель целиком,
// а `MaxHeaderBytes` у Go — мегабайт: без предела мегабайтная строка уехала бы
// в свёртку регистра и в сравнение со словарём.
const maxLocaleTag = 32

// Locale нормализует заголовок `Accept-Language` до языкового тега.
//
// Правило lookup RFC 4647: берётся первый тег списка, вес `q` отбрасывается,
// подтеги отсекаются, регистр сворачивается — `RU-ru,ru;q=0.9` даёт `ru`.
// Свёртка регистра обязательна: теги BCP 47 регистронезависимы, и без неё `RU`
// и `ru` были бы разными языками, а локаль сужает поиск по словарю.
//
// Согласования весов нет намеренно: измеренное значение заголовка — `ru`
// (находка 32), одна строка без вариантов. Появятся веса — правило стоит
// пересматривать целиком, а не дописывать.
//
// Не тег — пустая строка: `*`, пустой заголовок, мусор. Пустая локаль законна и
// вывода кода не отменяет (см. Code).
func Locale(header string) string {
	tag := header
	if i := strings.IndexAny(tag, ",;"); i >= 0 {
		tag = tag[:i]
	}
	tag = strings.TrimSpace(tag)
	if len(tag) > maxLocaleTag {
		return ""
	}
	if i := strings.IndexByte(tag, '-'); i >= 0 {
		tag = tag[:i]
	}
	if !isLanguageTag(tag) {
		return ""
	}
	return strings.ToLower(tag)
}

// isLanguageTag проверяет первичный подтег: от двух до восьми ASCII-букв.
//
// Проверка нужна не эстетике. Без неё `*` из `Accept-Language: *` стал бы
// полноценной локалью, а мусор из чужого заголовка — ключом поиска по словарю.
func isLanguageTag(s string) bool {
	if len(s) < 2 || len(s) > 8 {
		return false
	}
	for i := range len(s) {
		c := s[i]
		if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
			return false
		}
	}
	return true
}
