package main

import (
	"fmt"
	"io"
)

// writeReport печатает итог пересборки человеку.
//
// Отдельной функцией с io.Writer, а не печатью в os.Stdout из недр: отчёт —
// новая поверхность вывода, и единственное, что защищает её от утечки данных о
// здоровье, — тест. Тест на глобальном os.Stdout был бы тестом на глобальном
// состоянии, то есть его бы не написали.
//
// Ни значений точек, ни имён метрик, ни имён устройств здесь нет и быть не
// может: содержимое витрины входит в отчёт только отпечатком, а он берёт его
// хешем.
func writeReport(w io.Writer, r report) {
	p := func(format string, args ...any) {
		_, _ = fmt.Fprintf(w, format+"\n", args...)
	}

	p("пересборка витрины из журнала")
	p("  архив:       тел %d, пропущено файлов %d, повторов идентификатора %d",
		r.replay.Bodies, r.replay.SkippedFiles, r.replay.Duplicates)
	p("  учёт:        подобрано тел без записи %d, не удалось подобрать %d, записей без тела %d",
		r.replay.Adopted, r.replay.AdoptFailed, r.replay.Orphans)
	p("  свёрнуто:    %d; отказов: слой не выведен %d, содержимое %d, прочее %d, отложено %d",
		r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther,
		r.replay.Deferred)
	// Удержанные версии сущностей печатаются ВСЕГДА, а не только при ненулевом
	// значении: ноль здесь утверждение, а не отсутствие новостей. Отпечаток это
	// правило не проверяет по построению — живой приём и пересборка пользуются
	// одним правилом и одинаково сойдутся на одинаково удержанной версии, — так
	// что счётчик и есть единственный способ увидеть, что правило слияния
	// сущностей стало слишком строгим.
	p("  слияние:     частично разобрано %d, несравнимых наборов %d, удержано версий сущностей %d, версий одного ключа в одном теле %d",
		r.replay.Partial, r.replay.Incomparable, r.replay.EntitiesHeld, r.replay.EntitiesDiverging)
	// То же и по той же причине — про точки. Удержания говорят, спорит ли ещё
	// правило полноты с журналом; потери — единственное направление, в котором
	// тай-брейк «побеждает пришедшая» способен унести содержание, и человек,
	// принимающий по этому отчёту необратимое решение о подмене базы, обязан
	// видеть оба числа, а не выводить их из совпавшего отпечатка.
	p("  точки:       удержано полнотой %d, содержание унесено пришедшей %d",
		r.replay.PointsHeld, r.replay.PointsErased)

	if r.replay.Canceled {
		// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
		// отмене не снимались. Печатать их сравнение значило бы выдать
		// неизмеренное за измеренное — в единственном оракуле задачи.
		p("")
		p("прогон ОТМЕНЁН: сравнение не проводилось, файл назначения не создан")
		return
	}

	// «Часть журнала не прочитана» — отдельное состояние, и оно обязано быть
	// видно рядом с вердиктом отпечатков. Пропущенный симлинк на каталог уносит
	// из прогона целый месяц одной строкой в счётчике, а вердикт «СОВПАЛИ»
	// выдал бы сертификат воспроизводимости прогону, который этих тел не читал.
	partialJournal := r.replay.SkippedFiles > 0 || r.replay.Orphans > 0 || r.replay.Duplicates > 0
	// Нештатные отказы. Невыведенный слой сюда не входит: он есть в каждом
	// журнале, и предупреждать о нём значило бы отправлять человека искать
	// дефект там, где его нет. А вот «содержимое не разбирается» штатным не
	// является: тело один раз уже прошло проверку формы на приёме.
	//
	// Отложенные доставки (занятая база, отмена) сюда входят: пересборка идёт в
	// свежий файл при единственном писателе, и такая доставка в собранной
	// витрине просто отсутствует — вместе с теми, кто наследовал от неё слой.
	badFailures := r.replay.FailedOther > 0 || r.replay.FailedMalformed > 0 ||
		r.replay.AdoptFailed > 0 || r.replay.Deferred > 0

	if r.sourceMissing {
		p("  объектов:    %d", r.replay.Buckets)
		p("  тренировок:  %d", r.replay.Workouts)
		p("  записей:     %d", r.replay.Records)
		p("  строк реестра категориальных значений: %d", r.replay.Categories)
		p("")
		p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
		p("не восстанавливаются: в архиве их нет.")
	} else {
		// «Было / стало» — единственное, по чему можно судить о НАПРАВЛЕНИИ
		// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
		// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
		p("  объектов:    было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
		p("  тренировок:  было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
		p("  записей:     было %d, стало %d", r.sourceRecords, r.replay.Records)
		p("  строк реестра категориальных значений: было %d, стало %d",
			r.sourceCategories, r.replay.Categories)
		p("")
		p("  отпечаток рабочей:       %s", r.sourcePrint)
		p("  отпечаток пересобранной: %s", r.replay.Fingerprint)
		switch {
		case r.sourcePrint == r.replay.Fingerprint && !partialJournal:
			p("  отпечатки СОВПАЛИ — состояние воспроизводимо")
		case r.sourcePrint == r.replay.Fingerprint:
			p("  отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
		default:
			p("  отпечатки РАЗОШЛИСЬ")
			p("  ожидаемые причины: исправленный разбор; покрытая разбором новая")
			p("  секция (её единиц хранения в рабочей базе нет по построению);")
			p("  признак sealed не переносится (правила его выставления ещё нет)")
			if r.sourceCategories < r.replay.Categories {
				// Класс назван отдельно от факта расхождения: реестр появился
				// вместе с бинарём, и у витрины, свёрнутой прежним, его нет по
				// построению. Не назвав это, отчёт приучает человека
				// игнорировать расхождение — то есть обесценивает оракул ровно
				// там, где по нему принимается необратимое решение.
				//
				// Условие — НЕПОЛНОТА, а не пустота. Между выкаткой и прогоном
				// проходят дни: воркер успевает набрать частые значения (фазы
				// сна, контекст пульса) и не успевает редкие — имя тренировки,
				// которая с тех пор не повторялась. Проверка «в рабочей базе
				// реестра нет вовсе» такое состояние не ловила бы, и человек
				// получил бы безадресное «разошлись» при совпавших числах
				// объектов, тренировок и записей.
				p("  РЕЕСТР НЕПОЛОН: строк категориальных значений в рабочей базе %d,",
					r.sourceCategories)
				p("  в пересобранной %d — реестр наполняется по мере свёртки, а целиком",
					r.replay.Categories)
				p("  его даёт только пересборка. Расхождение объясняется этим и лечится ею же")
			}
			if partialJournal {
				p("  ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
				p("  объясняться этим, а не разбором")
			}
		}
	}

	if r.replay.Bodies == 0 || r.replay.Folded == 0 {
		p("")
		p("проигрывать было нечего: файл назначения не создан.")
		p("проверьте storage.archive_dir и каталог запуска — пустая витрина")
		p("совпадает по отпечатку с пустой витриной и выглядит идеальной сверкой")
		return
	}

	if d := r.sourceAfter - r.sourceBefore; d != 0 {
		// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве,
		// но не в собранном файле. Подмена стёрла бы их учёт вместе с
		// заголовками, восстановить которые неоткуда, — поэтому процедура здесь
		// не печатается вовсе.
		p("")
		p("за время прогона в рабочую базу приехало доставок: %d.", d)
		p("подменять этим файлом НЕЛЬЗЯ: учёта новых доставок в нём нет, а вместе")
		p("с ним пропали бы их заголовки. Остановите сервис и пересоберите заново.")
		return
	}

	p("")
	if partialJournal {
		p("ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА: пропущено файлов %d, записей без тела %d,",
			r.replay.SkippedFiles, r.replay.Orphans)
		p("повторов идентификатора %d. Пересобранная витрина беднее рабочей на",
			r.replay.Duplicates)
		p("объекты этих доставок — и на объекты тех, кто наследовал от них слой.")
		p("Проверьте каталог архива (симлинк на подкаталог обходом не читается)")
		p("по DEBUG-строкам лога, прежде чем подменять базу.")
		p("")
	}
	if badFailures {
		p("отказы, которых быть не должно (%d прочих, %d по содержимому, %d при подборе, %d отложено) —",
			r.replay.FailedOther, r.replay.FailedMalformed, r.replay.AdoptFailed, r.replay.Deferred)
		p("разберитесь по логу, прежде чем подменять базу.")
		p("")
	}
	p("собрано в %s", r.target)
	p("подмена — вручную и при ОСТАНОВЛЕННОМ сервисе: он держит файл открытым,")
	p("и переименование поверх живого процесса портит базу молча.")
	p("")
	p("  task down")
	p("  mv %s %s", r.target, r.dbPath)
	p("  rm -f %s-wal %s-shm", r.dbPath, r.dbPath)
	p("  task up")
}
