package main

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

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

// Тождество файла назначения определяется файлом, а не строкой пути: `..`,
// симлинк или другой префикс монтирования дают ту же цель при другой строке, а
// ошибка здесь означает проигрывание журнала прямо в живую рабочую базу.
func TestФайлНазначенияНеМожетБытьРабочейБазой(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	db := filepath.Join(dir, "healthlog.db")
	if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
		t.Fatalf("подготовка базы: %v", err)
	}
	link := filepath.Join(dir, "link.db")
	if err := os.Symlink(db, link); err != nil {
		t.Skipf("символические ссылки недоступны: %v", err)
	}

	cases := map[string]string{
		"тот же путь": db,
		// Строкой, а не через filepath.Join: он бы почистил путь, и случай
		// выродился бы в совпадение строк.
		"через родителя":       dir + "/sub/../healthlog.db",
		"символическая ссылка": link,
	}
	for name, out := range cases {
		if _, err := resolveTarget(db, out, false); err == nil {
			t.Errorf("%s: файл назначения %q принят за отдельный файл", name, out)
		}
	}
}

// Существующий файл не перезаписывается молча; умолчание — сосед рабочей базы,
// чтобы подмена оставалась переименованием внутри одной файловой системы.
func TestВыборФайлаНазначения(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	db := filepath.Join(dir, "healthlog.db")
	if err := os.WriteFile(db, []byte("db"), 0o600); err != nil {
		t.Fatalf("подготовка базы: %v", err)
	}

	tgt, err := resolveTarget(db, "", false)
	if err != nil {
		t.Fatalf("умолчание: %v", err)
	}
	if tgt.final != db+rebuildSuffix {
		t.Errorf("умолчание %q, ожидался сосед рабочей базы", tgt.final)
	}
	if filepath.Dir(tgt.partial) != filepath.Dir(tgt.final) {
		t.Errorf("промежуточный файл %q не рядом с результатом", tgt.partial)
	}

	busy := filepath.Join(dir, "занято.db")
	if err := os.WriteFile(busy, []byte("x"), 0o600); err != nil {
		t.Fatalf("подготовка файла: %v", err)
	}
	if _, err := resolveTarget(db, busy, false); err == nil {
		t.Error("существующий файл назначения принят без --force")
	}
	if _, err := resolveTarget(db, busy, true); err != nil {
		t.Errorf("--force не разрешил перезапись: %v", err)
	}
	if _, err := os.Stat(busy); err != nil {
		t.Error("проверка аргументов уже что-то удалила — решать это должен прогон")
	}
}

// Отчёт — новая поверхность вывода, и единственное, что защищает её от утечки
// данных о здоровье, это проверка. Поэтому рендер принимает io.Writer, а не
// печатает в os.Stdout из недр.
func TestОтчётНеРаскрываетДанныхОЗдоровье(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  116,
			Outcome: replay.Outcome{Folded: 116, Partial: 53},
			Buckets: 2049, Workouts: 2, Records: 2, Fingerprint: "aaaa",
		},
		target:         "/data/healthlog.db.rebuild",
		dbPath:         "/data/healthlog.db",
		sourcePrint:    "bbbb",
		sourceBuckets:  2040,
		sourceWorkouts: 0,
		sourceRecords:  0,
		sourceBefore:   116,
		sourceAfter:    116,
	})
	out := buf.String()

	// Ни одного слова, которым могло бы оказаться измерение, имя метрики или
	// устройства: в отчёт они попадают только через отпечаток, а он берёт
	// содержимое хешем.
	for _, forbidden := range []string{
		"heart_rate", "sleep_analysis", "active_energy", "qty",
		"Apple Watch", "iPhone", "value",
	} {
		if strings.Contains(out, forbidden) {
			t.Errorf("отчёт содержит %q", forbidden)
		}
	}

	// Расхождение отпечатков названо, и рядом — направление: «было/стало».
	// Отпечатки отвечают «да/нет», а решать по ним человеку необратимое.
	//
	// «Было/стало» обязано покрывать КАЖДУЮ единицу хранения: единица, которой
	// нет в счётчиках, делает расхождение безадресным — человек видит «не
	// совпало» при неизменившемся числе объектов. Класс «покрыта новая секция»
	// назван отдельно потому, что первый прогон после такого изменения
	// расходится гарантированно и штатно.
	for _, want := range []string{
		"РАЗОШЛИСЬ", "было 2040, стало 2049",
		"тренировок:  было 0, стало 2", "записей:     было 0, стало 2",
		"покрытая разбором новая", "task down", "mv ",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("отчёт не содержит %q", want)
		}
	}
}

// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве, но не
// в собранном файле: подмена стёрла бы их учёт вместе с заголовками, которые
// не восстанавливаются ниоткуда.
func TestПриездДоставокЗаПрогонОтменяетПодмену(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  116,
			Outcome: replay.Outcome{Folded: 116},
			Buckets: 2049, Fingerprint: "aaaa",
		},
		target:       "/data/healthlog.db.rebuild",
		dbPath:       "/data/healthlog.db",
		sourcePrint:  "aaaa",
		sourceBefore: 116,
		sourceAfter:  119,
	})
	out := buf.String()

	if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
		t.Error("процедура подмены напечатана, хотя учёт новых доставок в файл не попал")
	}
	if !strings.Contains(out, "приехало доставок: 3") {
		t.Errorf("отчёт не назвал приезд доставок: %s", out)
	}
}

// Пустой журнал выглядит идеальной сходимостью: отпечаток пустой витрины
// совпадает с отпечатком пустой витрины. Успехом он быть не может, и процедуру
// подмены печатать нельзя — человек заменил бы накопленное пустым.
func TestПустойЖурналНеПечатаетПроцедуруПодмены(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{Bodies: 0, Fingerprint: "same"},
		target: "/data/healthlog.db.rebuild",
		dbPath: "/data/healthlog.db",
		// Отпечатки совпадают: обе витрины пусты.
		sourcePrint: "same",
	})
	out := buf.String()

	if strings.Contains(out, "mv ") || strings.Contains(out, "task down") {
		t.Error("процедура подмены напечатана при пустом журнале")
	}
	if !strings.Contains(out, "нечего") {
		t.Error("отчёт не говорит, что проигрывать было нечего")
	}
}

// Отмена — не успех: файла назначения нет, подменять нечего.
func TestОтменённыйПрогонНеПечатаетПроцедуруПодмены(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay:       replay.Report{Bodies: 10, Outcome: replay.Outcome{Folded: 3}, Canceled: true},
		target:       "/data/healthlog.db.rebuild",
		dbPath:       "/data/healthlog.db",
		sourcePrint:  "bbbb",
		sourceBefore: 116,
	})
	out := buf.String()

	if strings.Contains(out, "mv ") {
		t.Error("процедура подмены напечатана после отмены")
	}
	if !strings.Contains(out, "ОТМЕНЁН") {
		t.Error("отмена не названа в отчёте")
	}
	// Ни отпечатки, ни разница доставок при отмене не снимались — печатать их
	// значило бы выдать неизмеренное за измеренное.
	for _, forbidden := range []string{"СОВПАЛИ", "РАЗОШЛИСЬ", "приехало доставок"} {
		if strings.Contains(out, forbidden) {
			t.Errorf("отчёт после отмены содержит %q — величина не измерялась", forbidden)
		}
	}
}

// Справка — не отказ: иначе `reindex -h` печатает usage и выходит со словом
// «fatal» и кодом 1, а это первое, что человек наберёт у команды с тремя
// флагами.
func TestСправкаНеЯвляетсяОтказом(t *testing.T) {
	if err := runReindex([]string{"-h"}); err != nil {
		t.Errorf("reindex -h вернул ошибку: %v", err)
	}
}

// Пропущенный файл, запись без тела или повтор означают, что часть журнала не
// прочитана. Вердикт «СОВПАЛИ — состояние воспроизводимо» тогда выдавал бы
// сертификат воспроизводимости прогону, который этих тел не читал.
func TestНепрочитаннаяЧастьЖурналаВидна(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  100,
			Outcome: replay.Outcome{Folded: 100},
			Buckets: 2049, Fingerprint: "aaaa",
			// Симлинк на каталог суток уносит из прогона целый месяц одной
			// строкой счётчика.
			SkippedFiles: 1,
		},
		target:        "/data/healthlog.db.rebuild",
		dbPath:        "/data/healthlog.db",
		sourcePrint:   "aaaa",
		sourceBuckets: 2049,
	})
	out := buf.String()

	if strings.Contains(out, "СОВПАЛИ — состояние воспроизводимо") {
		t.Error("вердикт о воспроизводимости выдан прогону, читавшему не весь журнал")
	}
	if !strings.Contains(out, "ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА") {
		t.Errorf("отчёт не предупредил о непрочитанной части журнала:\n%s", out)
	}
}

// Тело, разобранное приёмом, не может перестать разбираться: `content` — не
// штатный отказ, в отличие от невыведенного слоя.
func TestНеразобранноеСодержимоеПредупреждает(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  100,
			Outcome: replay.Outcome{Folded: 99, FailedMalformed: 1},
			Buckets: 2049, Fingerprint: "aaaa",
		},
		target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
		sourcePrint: "bbbb",
	})
	if !strings.Contains(buf.String(), "которых быть не должно") {
		t.Errorf("неразобранное содержимое не подняло предупреждения:\n%s", buf.String())
	}
}

// Штатный отказ — невыведенный слой — предупреждения поднимать не должен:
// такие доставки есть в каждом журнале.
func TestНевыведенныйСлойНеПоднимаетТревоги(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  100,
			Outcome: replay.Outcome{Folded: 98, FailedLayer: 2},
			Buckets: 2049, Fingerprint: "aaaa",
		},
		target: "/data/healthlog.db.rebuild", dbPath: "/data/healthlog.db",
		sourcePrint: "aaaa", sourceBuckets: 2049,
	})
	out := buf.String()
	if strings.Contains(out, "которых быть не должно") {
		t.Error("штатный отказ поднял тревогу — человека послали искать несуществующий дефект")
	}
	if !strings.Contains(out, "task down") {
		t.Error("процедура подмены не напечатана при штатном исходе")
	}
}

// Рабочей базы может не быть — но и тогда файл назначения не может совпасть с
// её путём: иначе витрина устанавливается на место, минуя правило «подмену
// делает человек при остановленном сервисе».
func TestФайлНазначенияНеМожетБытьПутёмОтсутствующейБазы(t *testing.T) {
	t.Parallel()

	db := filepath.Join(t.TempDir(), "healthlog.db")
	if _, err := resolveTarget(db, db, false); err == nil {
		t.Error("путь отсутствующей рабочей базы принят как файл назначения")
	}
	if _, err := resolveTarget(db, db, true); err == nil {
		t.Error("--force позволил собрать витрину прямо на место рабочей базы")
	}
}

// Число удержанных версий сущностей печатается ВСЕГДА, включая ноль: здесь ноль
// это утверждение, а не отсутствие новостей. Отпечаток правило слияния
// сущностей не проверяет по построению — живой приём и пересборка пользуются
// одним правилом и одинаково сойдутся на одинаково удержанной версии, — так что
// строка отчёта и есть единственный способ увидеть, что правило стало слишком
// строгим. Без этого теста её можно удалить, и гейт останется зелёным.
func TestОтчётВсегдаНазываетУдержанныеВерсии(t *testing.T) {
	t.Parallel()

	for _, held := range []int{0, 3} {
		var buf bytes.Buffer
		writeReport(&buf, report{replay: replay.Report{
			Outcome: replay.Outcome{EntitiesHeld: held, EntitiesDiverging: held + 1},
		}})
		out := buf.String()
		if !strings.Contains(out, fmt.Sprintf("удержано версий сущностей %d", held)) {
			t.Errorf("при удержаниях %d строки в отчёте нет:\n%s", held, out)
		}
		if !strings.Contains(out, fmt.Sprintf("версий одного ключа в одном теле %d", held+1)) {
			t.Errorf("второй счётчик не напечатан:\n%s", out)
		}
	}
}

// Реестр категориальных значений — четвёртая единица хранения витрины, и у
// витрины, свёрнутой прежним бинарём, его нет по построению. Расхождение
// отпечатков по нему одному законно, и отчёт обязан назвать это классом, а не
// оставить человека с безадресным «не совпало»: числа объектов, тренировок и
// записей при этом не меняются вовсе, а решение о подмене базы необратимо.
func TestОтчётНазываетПоявившийсяРеестр(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  116,
			Outcome: replay.Outcome{Folded: 116},
			Buckets: 2049, Workouts: 2, Records: 2, Categories: 11,
			Fingerprint: "aaaa",
		},
		target:           "/data/healthlog.db.rebuild",
		dbPath:           "/data/healthlog.db",
		sourcePrint:      "bbbb",
		sourceBuckets:    2049,
		sourceWorkouts:   2,
		sourceRecords:    2,
		sourceCategories: 0,
		sourceBefore:     116,
		sourceAfter:      116,
	})
	out := buf.String()

	for _, want := range []string{
		"строк реестра категориальных значений: было 0, стало 11",
		"РЕЕСТР НЕПОЛОН",
		"лечится ею же",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("отчёт не содержит %q:\n%s", want, out)
		}
	}

	// Сами строки реестра — данные о здоровье наравне со значением точки:
	// отчёт отвечает счётом, а не перечислением.
	for _, forbidden := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} {
		if strings.Contains(out, forbidden) {
			t.Errorf("отчёт содержит наблюдённую строку %q", forbidden)
		}
	}
}

// Реестр рабочей витрины непуст, но неполон — штатное состояние через сутки
// после выкатки: частые значения воркер набрал, редкое имя тренировки с тех пор
// не повторялось. Класс обязан называться и здесь, иначе человек получит
// безадресное «разошлись» при совпавших числах объектов, тренировок и записей —
// и научится игнорировать строку, по которой принимает необратимое решение.
func TestОтчётНазываетНеполныйРеестр(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  116,
			Outcome: replay.Outcome{Folded: 116},
			Buckets: 2049, Workouts: 2, Records: 2, Categories: 11,
			Fingerprint: "aaaa",
		},
		target:           "/data/healthlog.db.rebuild",
		dbPath:           "/data/healthlog.db",
		sourcePrint:      "bbbb",
		sourceBuckets:    2049,
		sourceWorkouts:   2,
		sourceRecords:    2,
		sourceCategories: 5,
		sourceBefore:     116,
		sourceAfter:      116,
	})
	out := buf.String()
	for _, want := range []string{"РЕЕСТР НЕПОЛОН", "в рабочей базе 5", "в пересобранной 11"} {
		if !strings.Contains(out, want) {
			t.Errorf("отчёт не содержит %q:\n%s", want, out)
		}
	}
}

// Совпавший реестр отдельным классом не объявляется: иначе строка звучала бы
// при каждом прогоне и перестала бы что-либо значить.
func TestОтчётНеОбъявляетРеестрПоявившимсяБезПричины(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	writeReport(&buf, report{
		replay: replay.Report{
			Bodies:  10,
			Outcome: replay.Outcome{Folded: 10},
			Buckets: 5, Categories: 11, Fingerprint: "aaaa",
		},
		target:           "/data/healthlog.db.rebuild",
		dbPath:           "/data/healthlog.db",
		sourcePrint:      "bbbb",
		sourceBuckets:    4,
		sourceCategories: 11,
		sourceBefore:     10,
		sourceAfter:      10,
	})
	if out := buf.String(); strings.Contains(out, "РЕЕСТР НЕПОЛОН") {
		t.Errorf("класс объявлен при совпавшем реестре рабочей витрины:\n%s", out)
	}
}
