package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"testing"
	"time"

	"git.vakhrushev.me/av/healthlog/internal/archive"
	"git.vakhrushev.me/av/healthlog/internal/config"
	"git.vakhrushev.me/av/healthlog/internal/fold"
	"git.vakhrushev.me/av/healthlog/internal/ident"
	"git.vakhrushev.me/av/healthlog/internal/store"
)

// setup собирает рабочее окружение команды: архив с телами и рабочую базу,
// наполненную живым приёмом.
func setup(t *testing.T, bodies int) *config.Config {
	t.Helper()

	dir := t.TempDir()
	cfg := &config.Config{}
	cfg.Storage.DBPath = filepath.Join(dir, "healthlog.db")
	cfg.Storage.ArchiveDir = filepath.Join(dir, "raw")
	cfg.Ingest.MaxBodyMB = 64
	cfg.Log.Level = "error"
	cfg.Log.Format = "json"

	arch, err := archive.New(cfg.Storage.ArchiveDir)
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(cfg.Storage.DBPath)
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	defer func() { _ = st.Close() }()

	body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "minute.json"))
	if err != nil {
		t.Fatalf("фикстура: %v", err)
	}

	f := fold.New(arch, st, 64<<20, slog.New(slog.DiscardHandler))
	at := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
	for i := range bodies {
		id := ident.NewID()
		rawPath, err := arch.Write(id, at, body)
		if err != nil {
			t.Fatalf("запись в архив: %v", err)
		}
		err = st.CreateDelivery(context.Background(), store.Delivery{
			ID: id, ReceivedAt: at.Add(time.Duration(i) * time.Second),
			AutomationID: "auto-1", Bytes: int64(len(body)), SHA256: "-",
			RawPath: rawPath, ParseStatus: store.ParsePending,
		})
		if err != nil {
			t.Fatalf("запись доставки: %v", err)
		}
		_, _ = f.Fold(context.Background(), id)
	}
	return cfg
}

func fingerprintOf(t *testing.T, path string) string {
	t.Helper()

	st, err := store.Open(path)
	if err != nil {
		t.Fatalf("база %s: %v", path, err)
	}
	defer func() { _ = st.Close() }()

	fp, err := st.Fingerprint(context.Background())
	if err != nil {
		t.Fatalf("отпечаток: %v", err)
	}
	return fp
}

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

	cfg := setup(t, 3)
	before := fingerprintOf(t, cfg.Storage.DBPath)

	tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
	if err != nil {
		t.Fatalf("файл назначения: %v", err)
	}
	rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
	if err != nil {
		t.Fatalf("пересборка: %v", err)
	}

	if rep.replay.Folded != 3 {
		t.Errorf("свёрнуто %d, ожидалось 3", rep.replay.Folded)
	}
	if fingerprintOf(t, cfg.Storage.DBPath) != before {
		t.Error("рабочая витрина изменилась")
	}
	if rep.sourcePrint != rep.replay.Fingerprint {
		t.Errorf("отпечатки разошлись при неизменном разборе:\n  %s\n  %s",
			rep.sourcePrint, rep.replay.Fingerprint)
	}

	// Результат появился под именем назначения, промежуточного файла не
	// осталось.
	if _, err := os.Stat(tgt.final); err != nil {
		t.Errorf("файла назначения нет: %v", err)
	}
	assertGone(t, tgt.partial)
}

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

	cfg := setup(t, 3)
	before := fingerprintOf(t, cfg.Storage.DBPath)

	tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
	if err != nil {
		t.Fatalf("файл назначения: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	rep, err := rebuild(ctx, cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
	if err != nil {
		t.Fatalf("пересборка: %v", err)
	}
	if !rep.replay.Canceled {
		t.Error("отмена не отмечена в отчёте")
	}

	assertGone(t, tgt.final)
	assertGone(t, tgt.partial)
	if fingerprintOf(t, cfg.Storage.DBPath) != before {
		t.Error("рабочая витрина изменилась при отменённой пересборке")
	}
}

// Пустой архив — отказ команды, а не идеальная сходимость двух пустых витрин.
func TestПустойАрхивЭтоОтказКоманды(t *testing.T) {
	t.Parallel()

	cfg := setup(t, 0)
	tgt, err := resolveTarget(cfg.Storage.DBPath, "", false)
	if err != nil {
		t.Fatalf("файл назначения: %v", err)
	}

	rep, err := rebuild(context.Background(), cfg, tgt, slog.New(slog.DiscardHandler), io.Discard)
	if err != nil {
		t.Fatalf("пересборка: %v", err)
	}
	if rep.replay.Bodies != 0 {
		t.Fatalf("тел %d, ожидался пустой архив", rep.replay.Bodies)
	}
	// Отпечатки при этом совпадают — обе витрины пусты. Именно поэтому пустой
	// журнал не может быть успехом.
	if rep.sourcePrint != rep.replay.Fingerprint {
		t.Error("две пустые витрины дали разные отпечатки — проверка потеряла смысл")
	}
	assertGone(t, tgt.final)
	assertGone(t, tgt.partial)
}

func assertGone(t *testing.T, path string) {
	t.Helper()

	for _, s := range []string{"", "-wal", "-shm"} {
		if _, err := os.Stat(path + s); err == nil {
			t.Errorf("остался файл %s", path+s)
		}
	}
}

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

	cfg := setup(t, 3)
	log := slog.New(slog.DiscardHandler)

	first, err := resolveTarget(cfg.Storage.DBPath, "", false)
	if err != nil {
		t.Fatalf("файл назначения: %v", err)
	}
	fresh, err := rebuild(context.Background(), cfg, first, log, io.Discard)
	if err != nil {
		t.Fatalf("первая пересборка: %v", err)
	}

	// Поверх уже существующего результата, с явно затребованной перезаписью.
	again, err := resolveTarget(cfg.Storage.DBPath, first.final, true)
	if err != nil {
		t.Fatalf("файл назначения (--force): %v", err)
	}
	over, err := rebuild(context.Background(), cfg, again, log, io.Discard)
	if err != nil {
		t.Fatalf("пересборка с перезаписью: %v", err)
	}

	if over.replay.Fingerprint != fresh.replay.Fingerprint {
		t.Errorf("перезапись дала другую витрину:\n  с нуля     %s\n  поверх     %s",
			fresh.replay.Fingerprint, over.replay.Fingerprint)
	}
	if over.replay.Buckets != fresh.replay.Buckets {
		t.Errorf("объектов %d против %d — сборка дописалась в старое содержимое",
			over.replay.Buckets, fresh.replay.Buckets)
	}
}

// Исход команды целиком: пустой архив даёт ненулевой код, а не «успех»
// с идеально совпавшими пустыми отпечатками.
func TestИсходКомандыНаПустомАрхиве(t *testing.T) {
	cfg := setup(t, 0)
	cfgPath := filepath.Join(t.TempDir(), "config.toml")
	writeConfig(t, cfgPath, cfg)

	err := runReindex([]string{"--config", cfgPath})
	if !errors.Is(err, errNothingReplayed) {
		t.Errorf("пустой архив дал %v, ожидался отказ «проигрывать нечего»", err)
	}
}

// И обратное: непустой журнал доводится до конца и завершается успехом.
func TestИсходКомандыНаНепустомАрхиве(t *testing.T) {
	cfg := setup(t, 2)
	cfgPath := filepath.Join(t.TempDir(), "config.toml")
	writeConfig(t, cfgPath, cfg)

	if err := runReindex([]string{"--config", cfgPath}); err != nil {
		t.Errorf("непустой журнал дал отказ: %v", err)
	}
	if _, err := os.Stat(cfg.Storage.DBPath + rebuildSuffix); err != nil {
		t.Errorf("файла назначения нет: %v", err)
	}
}

func writeConfig(t *testing.T, path string, cfg *config.Config) {
	t.Helper()

	body := fmt.Sprintf(`[storage]
db_path = %q
archive_dir = %q

[ingest]
max_body_mb = %d

[log]
level = "error"
format = "json"
`, cfg.Storage.DBPath, cfg.Storage.ArchiveDir, cfg.Ingest.MaxBodyMB)
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		t.Fatalf("конфиг: %v", err)
	}
}
