package pocketbase

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

	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/tools/filesystem"

	"git.vakhrushev.me/av/transcriber/internal/contract"
	"git.vakhrushev.me/av/transcriber/internal/entity"

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

// workFile — рабочая копия файла на диске. Живёт во временном каталоге
// системы, а не в каталоге данных: последний смонтирован на сервере, и
// временному там не место.
type workFile struct {
	path string
}

func (w *workFile) Path() string { return w.path }

func (w *workFile) Size() (int64, error) {
	info, err := os.Stat(w.path)
	if err != nil {
		return 0, fmt.Errorf("failed to stat work file: %w", err)
	}
	return info.Size(), nil
}

// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
	if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed to remove work file: %w", err)
	}
	return nil
}

type FileRepository struct {
	app core.App
}

func NewFileRepository(app core.App) *FileRepository {
	return &FileRepository{app: app}
}

// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
	f, err := os.CreateTemp("", "transcriber-*"+ext)
	if err != nil {
		return nil, fmt.Errorf("failed to create work file: %w", err)
	}
	path := f.Name()
	if err := f.Close(); err != nil {
		_ = os.Remove(path)
		return nil, fmt.Errorf("failed to close work file: %w", err)
	}
	return &workFile{path: path}, nil
}

func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
	return newWorkFile(ext)
}

func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
	work, err := newWorkFile(ext)
	if err != nil {
		return nil, err
	}

	if err := writeTo(work.path, content); err != nil {
		// Отказ уборки не подменяет отказ записи, но и не теряется.
		return nil, errors.Join(err, work.Close())
	}

	return work, nil
}

func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
	record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
	if err != nil {
		return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
	}

	name := firstFileName(record)
	if name == "" {
		return nil, fmt.Errorf("file %s has no content in storage", fileID)
	}

	work, err := newWorkFile(filepath.Ext(name))
	if err != nil {
		return nil, err
	}

	src, err := repo.openStored(record, name)
	if err != nil {
		return nil, errors.Join(err, work.Close())
	}
	defer src.Close()

	if err := writeTo(work.path, src); err != nil {
		return nil, errors.Join(err, work.Close())
	}

	return work, nil
}

// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки
// строит его из имени, данного отправителем, а имя отправителя в хранилище не
// попадает — путь к файлу читается в журнале, и инвариант приватности этого не
// допускает. Свой суффикс хранилище допишет само.
//
// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний
// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия
// файлом записи не считается, а её ключ живёт в строке попытки распознавания.
func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) {
	collection, err := findCollection(repo.app, migrations.FilesCollection)
	if err != nil {
		return nil, err
	}

	stored, err := filesystem.NewFileFromPath(work.Path())
	if err != nil {
		return nil, fmt.Errorf("failed to read work file: %w", err)
	}
	stored.Name = name

	record := core.NewRecord(collection)
	record.Set("file", stored)
	record.Set("location", entity.LocationLocal)
	record.Set("size", stored.Size)
	record.Set("format", meta.Format)
	record.Set("duration_ms", meta.DurationMs)
	// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
	// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
	// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
	// бы доступным всякому вошедшему.
	record.Set("owner", ownerID)

	if err := repo.app.Save(record); err != nil {
		// Отказ укладки называет имя файла — то самое, из которого строится
		// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
		// ключ при чтении.
		return nil, errors.New("failed to store file")
	}

	return recordToFile(record), nil
}

func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
	record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
	if err != nil {
		return nil, fmt.Errorf("failed to get file: %w", err)
	}
	return recordToFile(record), nil
}

func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
	record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
	if err != nil {
		return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
	}

	name := firstFileName(record)
	if name == "" {
		return nil, fmt.Errorf("file %s has no content in storage", fileID)
	}

	return repo.openStored(record, name)
}

// openStored открывает содержимое файла в хранилище потоком.
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
	fsys, err := repo.app.NewFilesystem()
	if err != nil {
		return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
	}

	reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name)
	if err != nil {
		// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
		// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
		// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
		// стала бы там бессрочным ключом к чужому аудио.
		return nil, errors.Join(
			fmt.Errorf("failed to read stored file of record %s", record.Id),
			fsys.Close(),
		)
	}

	return &storedReader{reader: reader, fsys: fsys}, nil
}

// storedReader держит открытой файловую систему хранилища на всё время чтения:
// закрытая раньше времени, она обрывает поток на середине записи.
type storedReader struct {
	reader io.ReadCloser
	fsys   io.Closer
}

func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }

func (r *storedReader) Close() error {
	readerErr := r.reader.Close()
	fsysErr := r.fsys.Close()
	switch {
	case readerErr != nil && fsysErr != nil:
		return errors.New("failed to close stored file and its filesystem")
	case readerErr != nil:
		return errors.New("failed to close stored file")
	default:
		return fsysErr
	}
}

// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
	dst, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("failed to open work file: %w", err)
	}

	if _, err := io.Copy(dst, content); err != nil {
		_ = dst.Close()
		return fmt.Errorf("failed to write work file: %w", err)
	}

	if err := dst.Close(); err != nil {
		return fmt.Errorf("failed to close work file: %w", err)
	}

	return nil
}

func firstFileName(record *core.Record) string {
	names := record.GetStringSlice("file")
	if len(names) == 0 {
		return ""
	}
	return names[0]
}

func recordToFile(record *core.Record) *entity.File {
	return &entity.File{
		Id:         record.Id,
		Location:   record.GetString("location"),
		FileName:   firstFileName(record),
		Size:       int64(record.GetInt("size")),
		Format:     record.GetString("format"),
		DurationMs: int64(record.GetInt("duration_ms")),
		CreatedAt:  record.GetDateTime("created").Time(),
	}
}
