package store_test

import (
	"context"
	"database/sql"
	"path/filepath"
	"testing"
	"time"

	"git.vakhrushev.me/av/healthlog/internal/store"

	_ "modernc.org/sqlite" // прямая запись в файл базы — ради теста на битую колонку
)

// seedUncovered заводит доставку с уже записанным исходом разбора: список
// непокрытых секций живёт в учётной записи, и заполняет его свёртка.
func seedUncovered(t *testing.T, st *store.Store, id string, at time.Time, status string, sections ...string) {
	t.Helper()

	seedPending(t, st, id, at)
	err := st.FinishParse(context.Background(), id, store.ParseOutcome{
		Status:    status,
		Layer:     "hour",
		Uncovered: sections,
	})
	if err != nil {
		t.Fatalf("исход разбора %q: %v", id, err)
	}
}

// Признак новизны обязан быть функцией ЖУРНАЛА, а не порядка свёртки: имя
// считается виденным, только если встречалось строго раньше по паре
// `(received_at, id)`.
func TestSectionsSeenBeforeСудитПоЖурналу(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()
	at := ts(t, "2026-08-01T12:00:00Z")

	early := at
	late := at.Add(time.Second)
	seedUncovered(t, st, "d-early", early, store.ParsePartial, "ecg")
	seedUncovered(t, st, "d-late", late, store.ParsePartial, "ecg")

	seen, err := st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-late", ReceivedAt: late})
	if err != nil {
		t.Fatalf("SectionsSeenBefore: %v", err)
	}
	if _, ok := seen["ecg"]; !ok {
		t.Error("для поздней доставки имя обязано быть виденным: раньше неё в журнале оно есть")
	}

	// Ранняя доставка судится тем же запросом и обязана признать имя новым —
	// собственная строка в счёт не идёт, а более поздняя лежит после неё.
	seen, err = st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-early", ReceivedAt: early})
	if err != nil {
		t.Fatalf("SectionsSeenBefore: %v", err)
	}
	if _, ok := seen["ecg"]; ok {
		t.Error("для ранней доставки имя обязано быть новым: раньше неё его в журнале нет")
	}
}

// Метка приёма хранится с секундной точностью, поэтому доставки одной секунды
// разводятся идентификатором — иначе исход зависел бы от того, какая из них
// свернулась первой.
func TestSectionsSeenBeforeРазводитОднуСекундуИдентификатором(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()
	at := ts(t, "2026-08-01T12:00:00Z")

	seedUncovered(t, st, "d-a", at, store.ParsePartial, "symptoms")
	seedUncovered(t, st, "d-b", at, store.ParsePartial, "symptoms")

	seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-b", ReceivedAt: at})
	if err != nil {
		t.Fatalf("SectionsSeenBefore: %v", err)
	}
	if _, ok := seen["symptoms"]; !ok {
		t.Error("для второй доставки той же секунды имя обязано быть виденным")
	}

	seen, err = st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-a", ReceivedAt: at})
	if err != nil {
		t.Fatalf("SectionsSeenBefore: %v", err)
	}
	if len(seen) != 0 {
		t.Errorf("для первой доставки той же секунды виденных имён быть не может, получено %v", seen)
	}
}

// Повторный вопрос обязан давать тот же ответ: на этом стоит воспроизводимость
// событий при пересборке журнала.
func TestSectionsSeenBeforeИдемпотентен(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()
	at := ts(t, "2026-08-01T12:00:00Z")

	seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg", "symptoms")
	seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "ecg")

	ref := store.DeliveryRef{ID: "d2", ReceivedAt: at.Add(time.Minute)}
	first, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref)
	if err != nil {
		t.Fatalf("SectionsSeenBefore: %v", err)
	}
	second, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref)
	if err != nil {
		t.Fatalf("SectionsSeenBefore: %v", err)
	}
	if len(first) != 1 || len(second) != 1 {
		t.Fatalf("виденных имён %d и %d, ожидалось по одному", len(first), len(second))
	}
	if _, ok := first["ecg"]; !ok {
		t.Error("`ecg` обязан быть виденным")
	}
	if _, ok := second["medications"]; ok {
		t.Error("`medications` журнал не приносил и виденным быть не может")
	}
}

// Перечень отвечает по всем статусам разбора: список непокрытых секций
// переживает отказ свёртки, и молчать о таком имени значило бы терять как раз
// подозрительное.
func TestUncoveredSectionsСчитаетВстречиИГраницы(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()
	at := ts(t, "2026-08-01T12:00:00Z")

	seedUncovered(t, st, "d1", at, store.ParsePartial, "symptoms")
	seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms", "ecg")
	// Отказавшая доставка: имя записано, статус — `failed`.
	seedUncovered(t, st, "d3", at.Add(2*time.Minute), store.ParseFailed, "ecg")

	got, total, err := st.UncoveredSections(ctx, 10)
	if err != nil {
		t.Fatalf("UncoveredSections: %v", err)
	}
	if total != 2 {
		t.Errorf("различных имён %d, ожидалось 2", total)
	}
	if len(got) != 2 {
		t.Fatalf("строк перечня %d, ожидалось 2", len(got))
	}

	// Порядок детерминирован — по имени.
	if got[0].Name != "ecg" || got[1].Name != "symptoms" {
		t.Fatalf("порядок строк %q, %q", got[0].Name, got[1].Name)
	}

	ecg := got[0]
	if ecg.Deliveries != 2 {
		t.Errorf("`ecg` принесли %d доставок, ожидалось 2", ecg.Deliveries)
	}
	if ecg.FirstDeliveryID != "d2" || ecg.LastDeliveryID != "d3" {
		t.Errorf("границы `ecg`: %q…%q, ожидалось d2…d3", ecg.FirstDeliveryID, ecg.LastDeliveryID)
	}
	if !ecg.FirstSeen.Equal(at.Add(time.Minute)) || !ecg.LastSeen.Equal(at.Add(2*time.Minute)) {
		t.Errorf("метки `ecg`: %s…%s", ecg.FirstSeen, ecg.LastSeen)
	}

	sym := got[1]
	if sym.Deliveries != 2 || sym.FirstDeliveryID != "d1" || sym.LastDeliveryID != "d2" {
		t.Errorf("`symptoms`: %d доставок, %q…%q", sym.Deliveries, sym.FirstDeliveryID, sym.LastDeliveryID)
	}
}

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

	st := open(t)
	ctx := context.Background()
	at := ts(t, "2026-08-01T12:00:00Z")

	seedUncovered(t, st, "d1", at, store.ParsePartial, "aaa", "bbb", "ccc")

	got, total, err := st.UncoveredSections(ctx, 2)
	if err != nil {
		t.Fatalf("UncoveredSections: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("строк перечня %d, ожидалось 2", len(got))
	}
	if total != 3 {
		t.Errorf("всего имён %d, ожидалось 3", total)
	}
}

// Пустой журнал и журнал без непокрытых секций отвечают пустым перечнем, а не
// отказом: колонка по умолчанию держит `[]`, и до json_each такие строки не
// доходят.
func TestUncoveredSectionsНаЧистомЖурнале(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()
	at := ts(t, "2026-08-01T12:00:00Z")

	seedUncovered(t, st, "d1", at, store.ParseDone)

	got, total, err := st.UncoveredSections(ctx, 10)
	if err != nil {
		t.Fatalf("UncoveredSections: %v", err)
	}
	if len(got) != 0 || total != 0 {
		t.Errorf("перечень %v, всего %d — ожидалась пустота", got, total)
	}
}

// Одна учётная запись с неразбираемым содержимым колонки не имеет права
// обесценить и сверку, и перечень: `json_each` над таким значением отвечает
// ошибкой и валит ВЕСЬ запрос, а не пропускает строку.
//
// Штатный путь записи такого значения не производит — тем хуже был бы отказ:
// он необъясним, а последствие вечно (сверка на каждой доставке говорила бы
// «не состоялась», команда отказывала бы целиком).
func TestUncoveredSectionsПереживаетБитуюСтроку(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	dbPath := filepath.Join(dir, "healthlog.db")
	st, err := store.Open(dbPath)
	if err != nil {
		t.Fatalf("открытие базы: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	ctx := context.Background()
	at := ts(t, "2026-08-01T12:00:00Z")
	seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg")
	seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms")

	db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
	if err != nil {
		t.Fatalf("прямое подключение: %v", err)
	}
	defer func() { _ = db.Close() }()
	if _, err := db.ExecContext(ctx,
		`UPDATE delivery SET uncovered_sections = '{не JSON' WHERE id = ?`, "d1"); err != nil {
		t.Fatalf("порча колонки: %v", err)
	}

	got, total, err := st.UncoveredSections(ctx, 10)
	if err != nil {
		t.Fatalf("перечень отказал целиком из-за одной строки: %v", err)
	}
	if total != 1 || len(got) != 1 || got[0].Name != "symptoms" {
		t.Errorf("перечень %v (всего %d), ожидалась одна уцелевшая секция", got, total)
	}

	seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"},
		store.DeliveryRef{ID: "d3", ReceivedAt: at.Add(2 * time.Minute)})
	if err != nil {
		t.Fatalf("сверка отказала из-за одной строки: %v", err)
	}
	if _, ok := seen["symptoms"]; !ok {
		t.Error("уцелевшая строка перестала участвовать в сверке")
	}
}
