package main

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"
	"time"

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

func at(t *testing.T, s string) time.Time {
	t.Helper()

	v, err := time.Parse(time.RFC3339, s)
	if err != nil {
		t.Fatalf("метка %q: %v", s, err)
	}
	return v.UTC()
}

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

	var buf bytes.Buffer
	writeUncovered(&buf, []store.UncoveredSection{{
		Name:            "ecg",
		Deliveries:      3,
		FirstSeen:       at(t, "2026-08-01T10:00:00Z"),
		FirstDeliveryID: "01AAA",
		LastSeen:        at(t, "2026-08-02T11:00:00Z"),
		LastDeliveryID:  "01BBB",
	}}, 1)

	out := buf.String()
	for _, want := range []string{"ecg", "3", "2026-08-01T10:00:00Z", "01AAA", "2026-08-02T11:00:00Z", "01BBB"} {
		if !strings.Contains(out, want) {
			t.Errorf("в выводе нет %q:\n%s", want, out)
		}
	}
}

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

	var buf bytes.Buffer
	writeUncovered(&buf, nil, 0)

	if strings.TrimSpace(buf.String()) == "" {
		t.Error("пустой перечень напечатал пустоту")
	}
	// И не обещает того, чего носитель не даёт: колонка отвечает про дожившие
	// учётные записи, а не про историю потока.
	if strings.Contains(buf.String(), "не приносил") {
		t.Errorf("пустой перечень говорит за весь поток:\n%s", buf.String())
	}
}

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

	rows := []store.UncoveredSection{{
		Name: "ecg", Deliveries: 1,
		FirstSeen: at(t, "2026-08-01T10:00:00Z"), FirstDeliveryID: "01AAA",
		LastSeen: at(t, "2026-08-01T10:00:00Z"), LastDeliveryID: "01AAA",
	}}
	for name, sections := range map[string][]store.UncoveredSection{
		"пустой":   nil,
		"непустой": rows,
	} {
		var buf bytes.Buffer
		writeUncovered(&buf, sections, int64(len(sections)))
		if !strings.Contains(buf.String(), "uncovered_sections") {
			t.Errorf("%s перечень не назвал носителя:\n%s", name, buf.String())
		}
		if !strings.Contains(buf.String(), "32") {
			t.Errorf("%s перечень не назвал границу списка:\n%s", name, buf.String())
		}
	}
}

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

	var buf bytes.Buffer
	writeUncovered(&buf, []store.UncoveredSection{{
		Name:            "ecg\x1b[31m\nfake",
		Deliveries:      1,
		FirstSeen:       at(t, "2026-08-01T10:00:00Z"),
		FirstDeliveryID: "01AAA",
		LastSeen:        at(t, "2026-08-01T10:00:00Z"),
		LastDeliveryID:  "01AAA",
	}}, 1)

	out := buf.String()
	if strings.Contains(out, "\x1b") {
		t.Errorf("управляющий байт доехал до терминала:\n%q", out)
	}
	// Строка перечня обязана остаться одной: перевод строки из имени разорвал
	// бы её надвое, и вторая половина читалась бы как отдельная секция.
	var rows int
	for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
		if strings.HasPrefix(line, `"`) {
			rows++
		}
	}
	if rows != 1 {
		t.Errorf("строк перечня %d, ожидалась одна:\n%q", rows, out)
	}
	if !strings.Contains(out, `\n`) {
		t.Errorf("перевод строки в имени не экранирован:\n%q", out)
	}
}

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

	var buf bytes.Buffer
	writeUncovered(&buf, []store.UncoveredSection{{
		Name:            "ecg",
		Deliveries:      1,
		FirstSeen:       at(t, "2026-08-01T10:00:00Z"),
		FirstDeliveryID: "01AAA",
		LastSeen:        at(t, "2026-08-01T10:00:00Z"),
		LastDeliveryID:  "01AAA",
	}}, 5)

	if !strings.Contains(buf.String(), "4") {
		t.Errorf("остаток не назван числом:\n%s", buf.String())
	}
}

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

	dir := t.TempDir()
	cfgPath := filepath.Join(dir, "config.toml")
	cfg := "[server]\naddr = \":8080\"\ningest_token = \"t\"\nread_token = \"r\"\n" +
		"[storage]\ndb_path = \"" + filepath.Join(dir, "нет.db") + "\"\n" +
		"raw_dir = \"" + filepath.Join(dir, "raw") + "\"\n"
	if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
		t.Fatalf("конфиг: %v", err)
	}

	if err := runUncovered([]string{"--config", cfgPath}); err == nil {
		t.Error("команда на несуществующей базе завершилась успехом")
	}
}

// Сквозной прогон: перечень, собранный командой, сходится с тем, что посчитано
// по ТЕЛАМ архива независимо от её кода.
//
// Оракул строится от тел намеренно: сверка вывода с `SELECT DISTINCT` по той же
// колонке тем же `json_each` доказывала бы только согласие кода с самим собой —
// и молчала бы обо всём, что команда добавляет сверх множества имён.
//
// Не параллельный: подменяет `os.Stdout`.
func TestПереченьСходитсяСТеламиАрхива(t *testing.T) {
	dir := t.TempDir()
	dbPath := filepath.Join(dir, "healthlog.db")
	rawDir := filepath.Join(dir, "raw")

	arch, err := archive.New(rawDir)
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(dbPath)
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	svc := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))

	body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "uncovered_sections.json"))
	if err != nil {
		t.Fatalf("тело: %v", err)
	}
	want := uncoveredInBody(t, body)
	if len(want) == 0 {
		t.Fatal("в теле нет непокрытых секций — проверять нечего")
	}

	ctx := context.Background()
	for _, id := range []string{"d1", "d2"} {
		at := store.Now()
		rawPath, err := arch.Write(id, at, body)
		if err != nil {
			t.Fatalf("запись в архив: %v", err)
		}
		err = st.CreateDelivery(ctx, store.Delivery{
			ID: id, ReceivedAt: at, AutomationID: "a1", Aggregation: "Minutes",
			Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath,
			ParseStatus: store.ParsePending,
		})
		if err != nil {
			t.Fatalf("учёт доставки: %v", err)
		}
		if _, err := svc.Fold(ctx, id); err != nil {
			t.Fatalf("свёртка %s: %v", id, err)
		}
	}
	// База закрывается до команды: та открывает её сама, только на чтение.
	if err := st.Close(); err != nil {
		t.Fatalf("закрытие базы: %v", err)
	}

	cfgPath := filepath.Join(dir, "config.toml")
	cfg := "[storage]\ndb_path = \"" + dbPath + "\"\narchive_dir = \"" + rawDir + "\"\n"
	if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
		t.Fatalf("конфиг: %v", err)
	}

	out := captureStdout(t, func() {
		if err := runUncovered([]string{"--config", cfgPath}); err != nil {
			t.Fatalf("команда: %v", err)
		}
	})

	for _, name := range want {
		if !strings.Contains(out, name) {
			t.Errorf("в выводе нет секции %q, которая есть в теле:\n%s", name, out)
		}
	}
	// Обе доставки принесли одно и то же тело, значит у каждой секции ровно две
	// доставки, а границы — первая и последняя.
	if !strings.Contains(out, "  2  ") && !strings.Contains(out, "\t2\t") {
		t.Errorf("число доставок в выводе не 2:\n%s", out)
	}
	if !strings.Contains(out, "d1") || !strings.Contains(out, "d2") {
		t.Errorf("границы встреч не названы обеими доставками:\n%s", out)
	}
}

// uncoveredInBody считает непокрытые секции ПО ТЕЛУ, не трогая разбор: ключи
// `data` минус три покрытых имени.
func uncoveredInBody(t *testing.T, body []byte) []string {
	t.Helper()

	var envelope struct {
		Data map[string]json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(body, &envelope); err != nil {
		t.Fatalf("тело не разбирается: %v", err)
	}
	covered := map[string]bool{"metrics": true, "workouts": true, "stateOfMind": true}
	var out []string
	for name := range envelope.Data {
		if !covered[name] {
			out = append(out, name)
		}
	}
	sort.Strings(out)
	return out
}

// captureStdout ловит пользовательский вывод команды.
func captureStdout(t *testing.T, run func()) string {
	t.Helper()

	r, w, err := os.Pipe()
	if err != nil {
		t.Fatalf("канал: %v", err)
	}
	saved := os.Stdout
	os.Stdout = w
	defer func() { os.Stdout = saved }()

	run()
	_ = w.Close()

	var buf bytes.Buffer
	if _, err := io.Copy(&buf, r); err != nil {
		t.Fatalf("чтение вывода: %v", err)
	}
	return buf.String()
}
