package sqlite

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"syscall"

	"github.com/pressly/goose/v3"

	"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
)

// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
// данных рядом с базой: замок принадлежит каталогу, а не машине.
const migrationLockFile = "migrate.lock"

// Migrate приводит схему к последнему шагу.
//
// # Порядок
//
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
// запрос и на каждый прогон воркера — вместо одной строки о причине их
// становятся сотни, и первопричина в них теряется.
//
// # Чем держится неделимость
//
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
//
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
// дают отказ сбора, а не молчаливый выбор одного.
//
// # Почему замок наш
//
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
// замка, который надо чистить руками, не остаётся.
//
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
	if logger == nil {
		logger = slog.Default()
	}

	provider, err := goose.NewProvider(
		goose.DialectSQLite3,
		db.Writer(),
		nil,
		goose.WithGoMigrations(migrations.All()...),
		// Глобальный список библиотеки не читается: перечень шагов приходит
		// доводом, и два провайдера в одном процессе за общее состояние не
		// спорят.
		goose.WithDisableGlobalRegistry(true),
	)
	if err != nil {
		return fmt.Errorf("failed to prepare schema migrations: %w", err)
	}

	return withMigrationLock(dataDir, func() error {
		results, err := provider.Up(ctx)
		if err != nil {
			// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
			// владелец сервиса по ней находит файл шага.
			return fmt.Errorf("failed to apply schema migration: %w", err)
		}

		for _, result := range results {
			logger.Info("Schema migration applied",
				"migration_version", result.Source.Version,
				"duration_ms", result.Duration.Milliseconds())
		}

		return nil
	})
}

// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
//
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
func withMigrationLock(dataDir string, run func() error) error {
	path := filepath.Join(dataDir, migrationLockFile)

	file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
	if err != nil {
		return fmt.Errorf("failed to open migration lock: %w", err)
	}
	// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
	// он принадлежит открытому файлу, а не процессу. С умершим процессом его
	// снимает ядро тем же движением — просроченного замка, который надо чистить
	// руками, не остаётся.
	defer func() { _ = file.Close() }()

	if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
		return fmt.Errorf("failed to lock the data directory for migration: %w", err)
	}

	return run()
}
