package sqlite

import (
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"

	"git.vakhrushev.me/av/transcriber/internal/ident"
)

// recordsDir — раздел каталога данных, в котором лежат файлы записей.
const recordsDir = "records"

// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие
// имена из обычного перечисления каталога, а сама приставка отличает
// незавершённую укладку от рабочего имени.
const tempPrefix = ".partial-"

// Store — файлы записей в каталоге данных.
//
// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии
// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а
// запись убирается целиком одним движением — плоский каталог, где копии
// различаются приставкой в имени, обращал бы уборку в перебор по маске.
//
// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем
// файла, ни именем каталога.
type Store struct {
	root string
}

// NewStore заводит раздел записей в каталоге данных.
func NewStore(dataDir string) *Store {
	return &Store{root: filepath.Join(dataDir, recordsDir)}
}

// dir — подкаталог одной записи.
func (s *Store) dir(recordID string) string {
	return filepath.Join(s.root, recordID)
}

// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни
// в ответ, ни в метку метрики.
func (s *Store) path(recordID, name string) string {
	return filepath.Join(s.dir(recordID), name)
}

// Put кладёт содержимое под рабочим именем **атомарно**.
//
// Содержимое пишется во временное имя в том же подкаталоге записи и
// переименовывается в рабочее только после того, как поток дочитан до конца без
// отказа. Временное имя берётся в том же каталоге потому, что переименование в
// его пределах не копирует содержимое и не может оборваться на середине.
//
// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в
// конвейер, оплатила распознавание и отдала расшифровку половины как готовый
// результат — атомарная укладка единственное, что этого не допускает.
//
// Отказ источника и отмена посреди потока кончаются одним исходом: временного
// имени не остаётся, рабочего имени не появляется.
func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) {
	if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil {
		return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err))
	}

	temp := s.path(recordID, tempPrefix+ident.New())
	file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
	if err != nil {
		return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err))
	}

	size, copyErr := io.Copy(file, src)
	syncErr := file.Sync()
	closeErr := file.Close()
	if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
		_ = os.Remove(temp)
		// Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в
		// журнале, журнал уезжает в собранные логи, откуда строку не убрать, —
		// но по причине владелец различает исчерпание места, отсутствие прав и
		// файловую систему только для чтения.
		return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err))
	}

	if err := os.Rename(temp, s.path(recordID, name)); err != nil {
		_ = os.Remove(temp)
		return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err))
	}

	return size, nil
}

// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла
// по диапазону читает кусок, а не файл целиком.
func (s *Store) Open(recordID, name string) (*os.File, error) {
	file, err := os.Open(s.path(recordID, name))
	if err != nil {
		// Отказ называет запись её идентификатором и не несёт имени файла:
		// имя — часть пути к чужому аудио. Причина при этом остаётся: «файла
		// нет» и «прав нет» ведут владельца к разным действиям.
		return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err))
	}
	return file, nil
}

// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и
// там, где укладка до него не дошла.
func (s *Store) Remove(recordID, name string) error {
	if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err))
	}
	return nil
}

// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя.
// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по
// отсутствию рабочего имени.
func (s *Store) HasTemporary(recordID string) (bool, error) {
	entries, err := os.ReadDir(s.dir(recordID))
	if err != nil {
		if os.IsNotExist(err) {
			return false, nil
		}
		return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err))
	}

	for _, entry := range entries {
		if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix {
			return true, nil
		}
	}

	return false, nil
}

// causeOf снимает с отказа файловой операции путь, оставляя причину.
//
// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна:
// по ней владелец различает исчерпание места, отсутствие прав и файловую систему
// только для чтения — три поломки, требующие трёх разных действий, а отказ
// укладки — единственная поверхность, на которой он их видит. Путь не нужен и
// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда
// строку потом не убрать.
//
// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` —
// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком:
// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под
// обёрткой и от её снятия не страдает.
//
// Соединённый отказ разбирается по частям: укладка складывает отказы записи,
// сброса и закрытия, и путь лежит в каждой из них.
func causeOf(err error) error {
	switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять
	case *os.PathError:
		return typed.Err
	case *os.LinkError:
		return typed.Err
	case interface{ Unwrap() []error }:
		parts := typed.Unwrap()
		causes := make([]error, 0, len(parts))
		for _, part := range parts {
			causes = append(causes, causeOf(part))
		}
		return errors.Join(causes...)
	}

	return err
}
