package sqlite

import (
	"context"
	"database/sql"
	"errors"
	"io"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"syscall"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"git.vakhrushev.me/av/transcriber/internal/clock"
	"git.vakhrushev.me/av/transcriber/internal/contract"
	"git.vakhrushev.me/av/transcriber/internal/entity"
	"git.vakhrushev.me/av/transcriber/internal/ident"
)

// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
// конфига.
func testSettings() Settings {
	return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
}

// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
// путём, каким это делает сервис при старте.
func newTestDB(t *testing.T) (*DB, *Store, string) {
	t.Helper()

	dir := t.TempDir()
	db, err := Open(dir, testSettings())
	require.NoError(t, err)
	t.Cleanup(func() {
		if err := db.Close(); err != nil {
			t.Logf("не удалось закрыть базу: %v", err)
		}
	})

	require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))

	return db, NewStore(dir), dir
}

// newOwner заводит учётную запись и отдаёт её идентификатор.
func newOwner(t *testing.T, db *DB) string {
	t.Helper()

	account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
	require.NoError(t, err)

	return account.ID
}

// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
// один раз после открытия, настроил бы одно соединение из многих.
func TestSettingsApplyToEveryConnection(t *testing.T) {
	db, _, _ := newTestDB(t)

	var mode string
	require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
	assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")

	var busy int
	require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
	assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")

	// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
	// **несколько** разом: одно настроенное соединение из четырёх — ровно та
	// поломка, ради которой настройка уехала в строку подключения.
	var wg sync.WaitGroup
	answers := make([]int, testSettings().ReadConnections)
	start := make(chan struct{})
	for i := range answers {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			conn, err := db.Reader().Conn(context.Background())
			if !assert.NoError(t, err) {
				return
			}
			defer func() { assert.NoError(t, conn.Close()) }()

			assert.NoError(t,
				conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
			// Соединение придерживается, пока спрашивают остальные: иначе пул
			// раздал бы всем одно и то же и правило проверило бы одну настройку
			// вместо четырёх.
			time.Sleep(10 * time.Millisecond)
		}()
	}
	close(start)
	wg.Wait()

	for i, answer := range answers {
		assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
	}

	// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
	// несуществующим владельцем отвергается обоими пулами.
	now := clock.Now().Format(timeLayout)
	insert := `INSERT INTO audio_records
	             (id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
	           VALUES (?, ?, 0, 0, ?, ?, ?, ?)`

	_, err := db.Writer().ExecContext(context.Background(), insert,
		ident.New(), ident.New(), entity.StateUploaded, now, now, now)
	require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")

	_, err = db.Reader().ExecContext(context.Background(), insert,
		ident.New(), ident.New(), entity.StateUploaded, now, now, now)
	require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
}

// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
func TestSettingsAreValidated(t *testing.T) {
	for name, settings := range map[string]Settings{
		"нулевое ожидание":   {BusyTimeoutMs: 0, ReadConnections: 4},
		"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
		"отрицательный пул":  {BusyTimeoutMs: 5000, ReadConnections: -1},
		"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
	} {
		t.Run(name, func(t *testing.T) {
			_, err := Open(t.TempDir(), settings)
			assert.Error(t, err, "старт на негодном числе прошёл молча")
		})
	}
}

// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
// записей не теряет.
func TestMigrateIsIdempotent(t *testing.T) {
	dir := t.TempDir()
	db, err := Open(dir, testSettings())
	require.NoError(t, err)
	defer func() { require.NoError(t, db.Close()) }()

	require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))

	owner := newOwner(t, db)

	require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))

	var login string
	require.NoError(t, db.Reader().
		QueryRowContext(context.Background(),
			"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
	assert.NotEmpty(t, login, "повторный накат потерял прежние строки")

	var applied int
	require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
	assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
}

// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
// освобождения, а не применяет шаги параллельно.
//
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
// именно его.
func TestMigrationLockSerializesRuns(t *testing.T) {
	dir := t.TempDir()

	var (
		mu      sync.Mutex
		inside  int
		overlap bool
	)

	hold := func() error {
		mu.Lock()
		inside++
		if inside > 1 {
			overlap = true
		}
		mu.Unlock()

		time.Sleep(50 * time.Millisecond)

		mu.Lock()
		inside--
		mu.Unlock()
		return nil
	}

	var wg sync.WaitGroup
	for range 3 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			assert.NoError(t, withMigrationLock(dir, hold))
		}()
	}
	wg.Wait()

	assert.False(t, overlap, "два наката шли одновременно: замок не держит")
}

// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
// схеме, отвечал бы отказом на каждый запрос.
func TestMigrateFailsLoudly(t *testing.T) {
	dir := t.TempDir()
	db, err := Open(dir, testSettings())
	require.NoError(t, err)
	defer func() { require.NoError(t, db.Close()) }()

	// Таблица уже занята чужой строкой: начальный шаг на такой базе не
	// применяется.
	_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
	require.NoError(t, err)

	err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))

	require.Error(t, err, "отказ шага прошёл молча")
	assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")

	// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
	// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
	// состояние — то самое, из-за которого следующий запуск применил бы шаг
	// второй раз и упал бы на заведённой таблице.
	var version int
	err = db.Reader().QueryRowContext(context.Background(),
		"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
	if err == nil {
		assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
	}

	var tables int
	require.NoError(t, db.Reader().QueryRowContext(context.Background(),
		"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
	assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
}

// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
// прошла бы молча.
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
	db, _, _ := newTestDB(t)

	tables := []string{
		"users", "files", "topics", "audio_records",
		"texts", "structures", "recognitions", "record_events",
	}

	seen := 0
	for _, table := range tables {
		rows, err := db.Reader().QueryContext(context.Background(),
			"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
		require.NoError(t, err)

		for rows.Next() {
			var (
				name       string
				columnType string
				dflt       any
			)
			require.NoError(t, rows.Scan(&name, &columnType, &dflt))

			if !isTimeColumn(name) {
				continue
			}
			seen++
			assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
			assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
		}
		require.NoError(t, rows.Err())
		closeRows(t, rows)
	}

	require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
}

// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
func closeRows(t *testing.T, rows *sql.Rows) {
	t.Helper()
	require.NoError(t, rows.Close())
}

func isTimeColumn(name string) bool {
	return strings.HasSuffix(name, "_at") || name == "delay_time"
}

// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
// отбор захвата одинаково: вид времени в схеме один.
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
	db, _, _ := newTestDB(t)

	owner := newOwner(t, db)
	records := NewAudioRecordRepository(db)

	byService := &entity.AudioRecord{
		Id:             ident.New(),
		OwnerID:        owner,
		State:          entity.StateUploaded,
		StateEnteredAt: clock.Now(),
	}
	require.NoError(t, records.Create(byService))

	byHand := ident.New()
	now := clock.Now().Format(timeLayout)
	_, err := db.Writer().ExecContext(context.Background(),
		`INSERT INTO audio_records
		   (id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
		 VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
		byHand, owner, entity.StateUploaded, now, now, now,
	)
	require.NoError(t, err)

	acquired := map[string]bool{}
	for range 2 {
		got, err := records.FindAndAcquire(entity.WorkingStages())
		require.NoError(t, err)
		acquired[got.ID] = true
	}

	assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
	assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
}

// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
func TestHotQueriesUseIndexes(t *testing.T) {
	db, _, _ := newTestDB(t)

	acquire := explain(t, db, `
		SELECT id FROM audio_records
		 WHERE state IN (?, ?)
		   AND halted_at IS NULL
		   AND (delay_time IS NULL OR delay_time < ?)
		   AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
		 ORDER BY created_at, id
		 LIMIT 1`,
		entity.StateUploaded, entity.StateNormalized, "now", "now")

	list := explain(t, db, `
		SELECT id FROM audio_records
		 WHERE owner_id = ?
		   AND (created_at < ? OR (created_at = ? AND id < ?))
		 ORDER BY created_at DESC, id DESC
		 LIMIT 31`,
		"owner", "now", "now", "id")

	for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
		assert.NotContains(t, plan, "SCAN audio_records",
			"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
		assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
		assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
	}
}

func explain(t *testing.T, db *DB, query string, args ...any) string {
	t.Helper()

	rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
	require.NoError(t, err)
	defer func() { require.NoError(t, rows.Close()) }()

	var plan strings.Builder
	for rows.Next() {
		var id, parent, notUsed int
		var detail string
		require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
		plan.WriteString(detail)
		plan.WriteString("; ")
	}
	require.NoError(t, rows.Err())

	return plan.String()
}

// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
// даёт паники.
func TestCloseIsIdempotent(t *testing.T) {
	db, err := Open(t.TempDir(), testSettings())
	require.NoError(t, err)

	require.NoError(t, db.Close())
	assert.NoError(t, db.Close(), "повторное закрытие отказало")
}

// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
// файла под рабочим именем, ни временного имени в подкаталоге записи.
func TestStorePutIsAtomic(t *testing.T) {
	_, store, dir := newTestDB(t)

	recordID := ident.New()

	_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
	require.Error(t, err, "отказ источника прошёл молча")

	_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
	assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")

	temporary, err := store.HasTemporary(recordID)
	require.NoError(t, err)
	assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
}

// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
// закрывший соединение на середине.
type brokenReader struct {
	sent bool
}

func (r *brokenReader) Read(p []byte) (int, error) {
	if !r.sent {
		r.sent = true
		copy(p, strings.Repeat("a", min(len(p), 64)))
		return min(len(p), 64), nil
	}
	return 0, errors.New("источник оборвался")
}

// Отказ укладки несёт причину и не несёт пути.
//
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
// каталога требуют трёх разных действий, а отказ укладки — единственная
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
	_, store, dir := newTestDB(t)

	// noPath судит вторую половину: ни каталога данных, ни временной приставки
	// в цепочке отказа быть не должно.
	noPath := func(t *testing.T, err error) {
		t.Helper()
		require.Error(t, err)
		assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
		assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
	}

	t.Run("места на диске нет", func(t *testing.T) {
		recordID := ident.New()
		_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
			path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
		})
		noPath(t, err)
		assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
	})

	t.Run("прав на подкаталог записи нет", func(t *testing.T) {
		recordID := ident.New()
		recordDir := filepath.Join(dir, recordsDir, recordID)
		require.NoError(t, os.MkdirAll(recordDir, 0o750))
		require.NoError(t, os.Chmod(recordDir, 0o500))
		t.Cleanup(func() {
			if err := os.Chmod(recordDir, 0o750); err != nil {
				t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
			}
		})

		_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
		noPath(t, err)
		assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
	})

	t.Run("подкаталогом записи занято не то", func(t *testing.T) {
		recordID := ident.New()
		require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
		require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))

		_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
		noPath(t, err)
		assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
	})

	t.Run("копии нет", func(t *testing.T) {
		_, err := store.Open(ident.New(), "voice.mp3")
		noPath(t, err)
		assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
	})
}

// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
// рабочей копии, и его отказ приходит ровно этой формой.
type diskFullReader struct {
	path string
}

func (r *diskFullReader) Read([]byte) (int, error) {
	return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
}

// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
// где лежит что-то из них, нет.
func TestCopiesOfRecordLiveTogether(t *testing.T) {
	db, store, dir := newTestDB(t)

	owner := newOwner(t, db)
	files := NewFileRepository(db, store)
	recordID := ident.New()

	for _, name := range []string{"original.mp3", "normalized.ogg"} {
		work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
		require.NoError(t, err)

		_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
		require.NoError(t, err)
		require.NoError(t, work.Close())
	}

	entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
	require.NoError(t, err)

	names := make([]string, 0, len(entries))
	for _, entry := range entries {
		names = append(names, entry.Name())
	}
	assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)

	records, err := os.ReadDir(filepath.Join(dir, recordsDir))
	require.NoError(t, err)
	assert.Len(t, records, 1, "второго места для копий записи не появляется")
}

// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
// не файл целиком.
func TestOpenGivesSeekableStream(t *testing.T) {
	db, store, _ := newTestDB(t)

	owner := newOwner(t, db)
	files := NewFileRepository(db, store)
	recordID := ident.New()

	work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
	require.NoError(t, err)
	file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
	require.NoError(t, err)
	require.NoError(t, work.Close())

	reader, err := files.Open(file.Id)
	require.NoError(t, err)
	defer func() { require.NoError(t, reader.Close()) }()

	_, err = reader.Seek(4, io.SeekStart)
	require.NoError(t, err)

	slice := make([]byte, 3)
	_, err = io.ReadFull(reader, slice)
	require.NoError(t, err)
	assert.Equal(t, "456", string(slice))
}
