package sqlite

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"io/fs"
	"path/filepath"
	"strings"
	"time"

	"github.com/doug-martin/goqu/v9"
	"github.com/doug-martin/goqu/v9/exp"
	"github.com/jmoiron/sqlx"
	"github.com/stashapp/stash/pkg/models"
	"gopkg.in/guregu/null.v4"
)

const (
	fileTable      = "files"
	videoFileTable = "video_files"
	imageFileTable = "image_files"
	fileIDColumn   = "file_id"

	videoCaptionsTable    = "video_captions"
	captionCodeColumn     = "language_code"
	captionFilenameColumn = "filename"
	captionTypeColumn     = "caption_type"
)

type basicFileRow struct {
	ID             models.FileID   `db:"id" goqu:"skipinsert"`
	Basename       string          `db:"basename"`
	ZipFileID      null.Int        `db:"zip_file_id"`
	ParentFolderID models.FolderID `db:"parent_folder_id"`
	Size           int64           `db:"size"`
	ModTime        Timestamp       `db:"mod_time"`
	CreatedAt      Timestamp       `db:"created_at"`
	UpdatedAt      Timestamp       `db:"updated_at"`
}

func (r *basicFileRow) fromBasicFile(o models.BaseFile) {
	r.ID = o.ID
	r.Basename = o.Basename
	r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID)
	r.ParentFolderID = o.ParentFolderID
	r.Size = o.Size
	r.ModTime = Timestamp{Timestamp: o.ModTime}
	r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
	r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
}

type videoFileRow struct {
	FileID           models.FileID `db:"file_id"`
	Format           string        `db:"format"`
	Width            int           `db:"width"`
	Height           int           `db:"height"`
	Duration         float64       `db:"duration"`
	VideoCodec       string        `db:"video_codec"`
	AudioCodec       string        `db:"audio_codec"`
	FrameRate        float64       `db:"frame_rate"`
	BitRate          int64         `db:"bit_rate"`
	Interactive      bool          `db:"interactive"`
	InteractiveSpeed null.Int      `db:"interactive_speed"`
}

func (f *videoFileRow) fromVideoFile(ff models.VideoFile) {
	f.FileID = ff.ID
	f.Format = ff.Format
	f.Width = ff.Width
	f.Height = ff.Height
	f.Duration = ff.Duration
	f.VideoCodec = ff.VideoCodec
	f.AudioCodec = ff.AudioCodec
	f.FrameRate = ff.FrameRate
	f.BitRate = ff.BitRate
	f.Interactive = ff.Interactive
	f.InteractiveSpeed = intFromPtr(ff.InteractiveSpeed)
}

type imageFileRow struct {
	FileID models.FileID `db:"file_id"`
	Format string        `db:"format"`
	Width  int           `db:"width"`
	Height int           `db:"height"`
}

func (f *imageFileRow) fromImageFile(ff models.ImageFile) {
	f.FileID = ff.ID
	f.Format = ff.Format
	f.Width = ff.Width
	f.Height = ff.Height
}

// we redefine this to change the columns around
// otherwise, we collide with the image file columns
type videoFileQueryRow struct {
	FileID           null.Int    `db:"file_id_video"`
	Format           null.String `db:"video_format"`
	Width            null.Int    `db:"video_width"`
	Height           null.Int    `db:"video_height"`
	Duration         null.Float  `db:"duration"`
	VideoCodec       null.String `db:"video_codec"`
	AudioCodec       null.String `db:"audio_codec"`
	FrameRate        null.Float  `db:"frame_rate"`
	BitRate          null.Int    `db:"bit_rate"`
	Interactive      null.Bool   `db:"interactive"`
	InteractiveSpeed null.Int    `db:"interactive_speed"`
}

func (f *videoFileQueryRow) resolve() *models.VideoFile {
	return &models.VideoFile{
		Format:           f.Format.String,
		Width:            int(f.Width.Int64),
		Height:           int(f.Height.Int64),
		Duration:         f.Duration.Float64,
		VideoCodec:       f.VideoCodec.String,
		AudioCodec:       f.AudioCodec.String,
		FrameRate:        f.FrameRate.Float64,
		BitRate:          f.BitRate.Int64,
		Interactive:      f.Interactive.Bool,
		InteractiveSpeed: nullIntPtr(f.InteractiveSpeed),
	}
}

func videoFileQueryColumns() []interface{} {
	table := videoFileTableMgr.table
	return []interface{}{
		table.Col("file_id").As("file_id_video"),
		table.Col("format").As("video_format"),
		table.Col("width").As("video_width"),
		table.Col("height").As("video_height"),
		table.Col("duration"),
		table.Col("video_codec"),
		table.Col("audio_codec"),
		table.Col("frame_rate"),
		table.Col("bit_rate"),
		table.Col("interactive"),
		table.Col("interactive_speed"),
	}
}

// we redefine this to change the columns around
// otherwise, we collide with the video file columns
type imageFileQueryRow struct {
	Format null.String `db:"image_format"`
	Width  null.Int    `db:"image_width"`
	Height null.Int    `db:"image_height"`
}

func (imageFileQueryRow) columns(table *table) []interface{} {
	ex := table.table
	return []interface{}{
		ex.Col("format").As("image_format"),
		ex.Col("width").As("image_width"),
		ex.Col("height").As("image_height"),
	}
}

func (f *imageFileQueryRow) resolve() *models.ImageFile {
	return &models.ImageFile{
		Format: f.Format.String,
		Width:  int(f.Width.Int64),
		Height: int(f.Height.Int64),
	}
}

type fileQueryRow struct {
	FileID         null.Int      `db:"file_id"`
	Basename       null.String   `db:"basename"`
	ZipFileID      null.Int      `db:"zip_file_id"`
	ParentFolderID null.Int      `db:"parent_folder_id"`
	Size           null.Int      `db:"size"`
	ModTime        NullTimestamp `db:"mod_time"`
	CreatedAt      NullTimestamp `db:"file_created_at"`
	UpdatedAt      NullTimestamp `db:"file_updated_at"`

	ZipBasename   null.String `db:"zip_basename"`
	ZipFolderPath null.String `db:"zip_folder_path"`

	FolderPath null.String `db:"parent_folder_path"`
	fingerprintQueryRow
	videoFileQueryRow
	imageFileQueryRow
}

func (r *fileQueryRow) resolve() models.File {
	basic := &models.BaseFile{
		ID: models.FileID(r.FileID.Int64),
		DirEntry: models.DirEntry{
			ZipFileID: nullIntFileIDPtr(r.ZipFileID),
			ModTime:   r.ModTime.Timestamp,
		},
		Path:           filepath.Join(r.FolderPath.String, r.Basename.String),
		ParentFolderID: models.FolderID(r.ParentFolderID.Int64),
		Basename:       r.Basename.String,
		Size:           r.Size.Int64,
		CreatedAt:      r.CreatedAt.Timestamp,
		UpdatedAt:      r.UpdatedAt.Timestamp,
	}

	if basic.ZipFileID != nil && r.ZipFolderPath.Valid && r.ZipBasename.Valid {
		basic.ZipFile = &models.BaseFile{
			ID:       *basic.ZipFileID,
			Path:     filepath.Join(r.ZipFolderPath.String, r.ZipBasename.String),
			Basename: r.ZipBasename.String,
		}
	}

	var ret models.File = basic

	if r.videoFileQueryRow.Format.Valid {
		vf := r.videoFileQueryRow.resolve()
		vf.BaseFile = basic
		ret = vf
	}

	if r.imageFileQueryRow.Format.Valid {
		imf := r.imageFileQueryRow.resolve()
		imf.BaseFile = basic
		ret = imf
	}

	r.appendRelationships(basic)

	return ret
}

func appendFingerprintsUnique(vs []models.Fingerprint, v ...models.Fingerprint) []models.Fingerprint {
	for _, vv := range v {
		found := false
		for _, vsv := range vs {
			if vsv.Type == vv.Type {
				found = true
				break
			}
		}

		if !found {
			vs = append(vs, vv)
		}
	}
	return vs
}

func (r *fileQueryRow) appendRelationships(i *models.BaseFile) {
	if r.fingerprintQueryRow.valid() {
		i.Fingerprints = appendFingerprintsUnique(i.Fingerprints, r.fingerprintQueryRow.resolve())
	}
}

type fileQueryRows []fileQueryRow

func (r fileQueryRows) resolve() []models.File {
	var ret []models.File
	var last models.File
	var lastID models.FileID

	for _, row := range r {
		if last == nil || lastID != models.FileID(row.FileID.Int64) {
			f := row.resolve()
			last = f
			lastID = models.FileID(row.FileID.Int64)
			ret = append(ret, last)
			continue
		}

		// must be merging with previous row
		row.appendRelationships(last.Base())
	}

	return ret
}

type FileStore struct {
	repository

	tableMgr *table
}

func NewFileStore() *FileStore {
	return &FileStore{
		repository: repository{
			tableName: sceneTable,
			idColumn:  idColumn,
		},

		tableMgr: fileTableMgr,
	}
}

func (qb *FileStore) table() exp.IdentifierExpression {
	return qb.tableMgr.table
}

func (qb *FileStore) Create(ctx context.Context, f models.File) error {
	var r basicFileRow
	r.fromBasicFile(*f.Base())

	id, err := qb.tableMgr.insertID(ctx, r)
	if err != nil {
		return err
	}

	fileID := models.FileID(id)

	// create extended stuff here
	switch ef := f.(type) {
	case *models.VideoFile:
		if err := qb.createVideoFile(ctx, fileID, *ef); err != nil {
			return err
		}
	case *models.ImageFile:
		if err := qb.createImageFile(ctx, fileID, *ef); err != nil {
			return err
		}
	}

	if err := FingerprintReaderWriter.insertJoins(ctx, fileID, f.Base().Fingerprints); err != nil {
		return err
	}

	updated, err := qb.Find(ctx, fileID)
	if err != nil {
		return fmt.Errorf("finding after create: %w", err)
	}

	base := f.Base()
	*base = *updated[0].Base()

	return nil
}

func (qb *FileStore) Update(ctx context.Context, f models.File) error {
	var r basicFileRow
	r.fromBasicFile(*f.Base())

	id := f.Base().ID

	if err := qb.tableMgr.updateByID(ctx, id, r); err != nil {
		return err
	}

	// create extended stuff here
	switch ef := f.(type) {
	case *models.VideoFile:
		if err := qb.updateOrCreateVideoFile(ctx, id, *ef); err != nil {
			return err
		}
	case *models.ImageFile:
		if err := qb.updateOrCreateImageFile(ctx, id, *ef); err != nil {
			return err
		}
	}

	if err := FingerprintReaderWriter.replaceJoins(ctx, id, f.Base().Fingerprints); err != nil {
		return err
	}

	return nil
}

// ModifyFingerprints updates existing fingerprints and adds new ones.
func (qb *FileStore) ModifyFingerprints(ctx context.Context, fileID models.FileID, fingerprints []models.Fingerprint) error {
	return FingerprintReaderWriter.upsertJoins(ctx, fileID, fingerprints)
}

func (qb *FileStore) DestroyFingerprints(ctx context.Context, fileID models.FileID, types []string) error {
	return FingerprintReaderWriter.destroyJoins(ctx, fileID, types)
}

func (qb *FileStore) Destroy(ctx context.Context, id models.FileID) error {
	return qb.tableMgr.destroyExisting(ctx, []int{int(id)})
}

func (qb *FileStore) createVideoFile(ctx context.Context, id models.FileID, f models.VideoFile) error {
	var r videoFileRow
	r.fromVideoFile(f)
	r.FileID = id
	if _, err := videoFileTableMgr.insert(ctx, r); err != nil {
		return err
	}

	return nil
}

func (qb *FileStore) updateOrCreateVideoFile(ctx context.Context, id models.FileID, f models.VideoFile) error {
	exists, err := videoFileTableMgr.idExists(ctx, id)
	if err != nil {
		return err
	}

	if !exists {
		return qb.createVideoFile(ctx, id, f)
	}

	var r videoFileRow
	r.fromVideoFile(f)
	r.FileID = id
	if err := videoFileTableMgr.updateByID(ctx, id, r); err != nil {
		return err
	}

	return nil
}

func (qb *FileStore) createImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error {
	var r imageFileRow
	r.fromImageFile(f)
	r.FileID = id
	if _, err := imageFileTableMgr.insert(ctx, r); err != nil {
		return err
	}

	return nil
}

func (qb *FileStore) updateOrCreateImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error {
	exists, err := imageFileTableMgr.idExists(ctx, id)
	if err != nil {
		return err
	}

	if !exists {
		return qb.createImageFile(ctx, id, f)
	}

	var r imageFileRow
	r.fromImageFile(f)
	r.FileID = id
	if err := imageFileTableMgr.updateByID(ctx, id, r); err != nil {
		return err
	}

	return nil
}

func (qb *FileStore) selectDataset() *goqu.SelectDataset {
	table := qb.table()

	folderTable := folderTableMgr.table
	fingerprintTable := fingerprintTableMgr.table
	videoFileTable := videoFileTableMgr.table
	imageFileTable := imageFileTableMgr.table

	zipFileTable := table.As("zip_files")
	zipFolderTable := folderTable.As("zip_files_folders")

	cols := []interface{}{
		table.Col("id").As("file_id"),
		table.Col("basename"),
		table.Col("zip_file_id"),
		table.Col("parent_folder_id"),
		table.Col("size"),
		table.Col("mod_time"),
		table.Col("created_at").As("file_created_at"),
		table.Col("updated_at").As("file_updated_at"),
		folderTable.Col("path").As("parent_folder_path"),
		fingerprintTable.Col("type").As("fingerprint_type"),
		fingerprintTable.Col("fingerprint"),
		zipFileTable.Col("basename").As("zip_basename"),
		zipFolderTable.Col("path").As("zip_folder_path"),
	}

	cols = append(cols, videoFileQueryColumns()...)
	cols = append(cols, imageFileQueryRow{}.columns(imageFileTableMgr)...)

	ret := dialect.From(table).Select(cols...)

	return ret.InnerJoin(
		folderTable,
		goqu.On(table.Col("parent_folder_id").Eq(folderTable.Col(idColumn))),
	).LeftJoin(
		fingerprintTable,
		goqu.On(table.Col(idColumn).Eq(fingerprintTable.Col(fileIDColumn))),
	).LeftJoin(
		videoFileTable,
		goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))),
	).LeftJoin(
		imageFileTable,
		goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))),
	).LeftJoin(
		zipFileTable,
		goqu.On(table.Col("zip_file_id").Eq(zipFileTable.Col("id"))),
	).LeftJoin(
		zipFolderTable,
		goqu.On(zipFileTable.Col("parent_folder_id").Eq(zipFolderTable.Col(idColumn))),
	)
}

func (qb *FileStore) countDataset() *goqu.SelectDataset {
	table := qb.table()

	folderTable := folderTableMgr.table
	fingerprintTable := fingerprintTableMgr.table
	videoFileTable := videoFileTableMgr.table
	imageFileTable := imageFileTableMgr.table

	zipFileTable := table.As("zip_files")
	zipFolderTable := folderTable.As("zip_files_folders")

	ret := dialect.From(table).Select(goqu.COUNT(goqu.DISTINCT(table.Col("id"))))

	return ret.InnerJoin(
		folderTable,
		goqu.On(table.Col("parent_folder_id").Eq(folderTable.Col(idColumn))),
	).LeftJoin(
		fingerprintTable,
		goqu.On(table.Col(idColumn).Eq(fingerprintTable.Col(fileIDColumn))),
	).LeftJoin(
		videoFileTable,
		goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))),
	).LeftJoin(
		imageFileTable,
		goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))),
	).LeftJoin(
		zipFileTable,
		goqu.On(table.Col("zip_file_id").Eq(zipFileTable.Col("id"))),
	).LeftJoin(
		zipFolderTable,
		goqu.On(zipFileTable.Col("parent_folder_id").Eq(zipFolderTable.Col(idColumn))),
	)
}

func (qb *FileStore) get(ctx context.Context, q *goqu.SelectDataset) (models.File, error) {
	ret, err := qb.getMany(ctx, q)
	if err != nil {
		return nil, err
	}

	if len(ret) == 0 {
		return nil, sql.ErrNoRows
	}

	return ret[0], nil
}

func (qb *FileStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]models.File, error) {
	const single = false
	var rows fileQueryRows
	if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {
		var f fileQueryRow
		if err := r.StructScan(&f); err != nil {
			return err
		}

		rows = append(rows, f)
		return nil
	}); err != nil {
		return nil, err
	}

	return rows.resolve(), nil
}

func (qb *FileStore) Find(ctx context.Context, ids ...models.FileID) ([]models.File, error) {
	var files []models.File
	for _, id := range ids {
		file, err := qb.find(ctx, id)
		if err != nil {
			return nil, err
		}

		if file == nil {
			return nil, fmt.Errorf("file with id %d not found", id)
		}

		files = append(files, file)
	}

	return files, nil
}

func (qb *FileStore) find(ctx context.Context, id models.FileID) (models.File, error) {
	q := qb.selectDataset().Where(qb.tableMgr.byID(id))

	ret, err := qb.get(ctx, q)
	if err != nil {
		return nil, fmt.Errorf("getting file by id %d: %w", id, err)
	}

	return ret, nil
}

// FindByPath returns the first file that matches the given path. Wildcard characters are supported.
func (qb *FileStore) FindByPath(ctx context.Context, p string) (models.File, error) {

	ret, err := qb.FindAllByPath(ctx, p)

	if err != nil {
		return nil, err
	}

	if len(ret) == 0 {
		return nil, nil
	}

	return ret[0], nil
}

// FindAllByPath returns all the files that match the given path.
// Wildcard characters are supported.
func (qb *FileStore) FindAllByPath(ctx context.Context, p string) ([]models.File, error) {
	// separate basename from path
	basename := filepath.Base(p)
	dirName := filepath.Dir(p)

	// replace wildcards
	basename = strings.ReplaceAll(basename, "*", "%")
	dirName = strings.ReplaceAll(dirName, "*", "%")

	table := qb.table()
	folderTable := folderTableMgr.table

	// like uses case-insensitive matching. Only use like if wildcards are used
	q := qb.selectDataset().Prepared(true)

	if strings.Contains(basename, "%") || strings.Contains(dirName, "%") {
		q = q.Where(
			folderTable.Col("path").Like(dirName),
			table.Col("basename").Like(basename),
		)
	} else {
		q = q.Where(
			folderTable.Col("path").Eq(dirName),
			table.Col("basename").Eq(basename),
		)
	}

	ret, err := qb.getMany(ctx, q)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("getting file by path %s: %w", p, err)
	}

	return ret, nil
}

func (qb *FileStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset {
	folderTable := folderTableMgr.table

	var conds []exp.Expression
	for _, pp := range p {
		ppWildcard := pp + string(filepath.Separator) + "%"

		conds = append(conds, folderTable.Col("path").Eq(pp), folderTable.Col("path").Like(ppWildcard))
	}

	return q.Where(
		goqu.Or(conds...),
	)
}

// FindAllByPaths returns the all files that are within any of the given paths.
// Returns all if limit is < 0.
// Returns all files if p is empty.
func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]models.File, error) {
	table := qb.table()
	folderTable := folderTableMgr.table

	q := dialect.From(table).Prepared(true).InnerJoin(
		folderTable,
		goqu.On(table.Col("parent_folder_id").Eq(folderTable.Col(idColumn))),
	).Select(table.Col(idColumn))

	q = qb.allInPaths(q, p)

	if limit > -1 {
		q = q.Limit(uint(limit))
	}

	q = q.Offset(uint(offset))

	ret, err := qb.findBySubquery(ctx, q)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("getting files by path %s: %w", p, err)
	}

	return ret, nil
}

// CountAllInPaths returns a count of all files that are within any of the given paths.
// Returns count of all files if p is empty.
func (qb *FileStore) CountAllInPaths(ctx context.Context, p []string) (int, error) {
	q := qb.countDataset().Prepared(true)
	q = qb.allInPaths(q, p)

	return count(ctx, q)
}

func (qb *FileStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]models.File, error) {
	table := qb.table()

	q := qb.selectDataset().Prepared(true).Where(
		table.Col(idColumn).Eq(
			sq,
		),
	)

	return qb.getMany(ctx, q)
}

func (qb *FileStore) FindByFingerprint(ctx context.Context, fp models.Fingerprint) ([]models.File, error) {
	fingerprintTable := fingerprintTableMgr.table

	fingerprints := fingerprintTable.As("fp")

	sq := dialect.From(fingerprints).Select(fingerprints.Col(fileIDColumn)).Where(
		fingerprints.Col("type").Eq(fp.Type),
		fingerprints.Col("fingerprint").Eq(fp.Fingerprint),
	)

	return qb.findBySubquery(ctx, sq)
}

func (qb *FileStore) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]models.File, error) {
	table := qb.table()

	q := qb.selectDataset().Prepared(true).Where(
		table.Col("zip_file_id").Eq(zipFileID),
	)

	return qb.getMany(ctx, q)
}

// FindByFileInfo finds files that match the base name, size, and mod time of the given file.
func (qb *FileStore) FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]models.File, error) {
	table := qb.table()

	modTime := info.ModTime().Format(time.RFC3339)

	q := qb.selectDataset().Prepared(true).Where(
		table.Col("basename").Eq(info.Name()),
		table.Col("size").Eq(size),
		table.Col("mod_time").Eq(modTime),
	)

	return qb.getMany(ctx, q)
}

func (qb *FileStore) CountByFolderID(ctx context.Context, folderID models.FolderID) (int, error) {
	table := qb.table()

	q := qb.countDataset().Prepared(true).Where(
		table.Col("parent_folder_id").Eq(folderID),
	)

	return count(ctx, q)
}

func (qb *FileStore) IsPrimary(ctx context.Context, fileID models.FileID) (bool, error) {
	joinTables := []exp.IdentifierExpression{
		scenesFilesJoinTable,
		galleriesFilesJoinTable,
		imagesFilesJoinTable,
	}

	var sq *goqu.SelectDataset

	for _, t := range joinTables {
		qq := dialect.From(t).Select(t.Col(fileIDColumn)).Where(
			t.Col(fileIDColumn).Eq(fileID),
			t.Col("primary").Eq(1),
		)

		if sq == nil {
			sq = qq
		} else {
			sq = sq.Union(qq)
		}
	}

	q := dialect.Select(goqu.COUNT("*").As("count")).Prepared(true).From(
		sq,
	)

	var ret int
	if err := querySimple(ctx, q, &ret); err != nil {
		return false, err
	}

	return ret > 0, nil
}

func (qb *FileStore) validateFilter(fileFilter *models.FileFilterType) error {
	const and = "AND"
	const or = "OR"
	const not = "NOT"

	if fileFilter.And != nil {
		if fileFilter.Or != nil {
			return illegalFilterCombination(and, or)
		}
		if fileFilter.Not != nil {
			return illegalFilterCombination(and, not)
		}

		return qb.validateFilter(fileFilter.And)
	}

	if fileFilter.Or != nil {
		if fileFilter.Not != nil {
			return illegalFilterCombination(or, not)
		}

		return qb.validateFilter(fileFilter.Or)
	}

	if fileFilter.Not != nil {
		return qb.validateFilter(fileFilter.Not)
	}

	return nil
}

func (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilterType) *filterBuilder {
	query := &filterBuilder{}

	if fileFilter.And != nil {
		query.and(qb.makeFilter(ctx, fileFilter.And))
	}
	if fileFilter.Or != nil {
		query.or(qb.makeFilter(ctx, fileFilter.Or))
	}
	if fileFilter.Not != nil {
		query.not(qb.makeFilter(ctx, fileFilter.Not))
	}

	query.handleCriterion(ctx, pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil))

	return query
}

func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) {
	fileFilter := options.FileFilter
	findFilter := options.FindFilter

	if fileFilter == nil {
		fileFilter = &models.FileFilterType{}
	}
	if findFilter == nil {
		findFilter = &models.FindFilterType{}
	}

	query := qb.newQuery()
	query.join(folderTable, "", "files.parent_folder_id = folders.id")

	distinctIDs(&query, fileTable)

	if q := findFilter.Q; q != nil && *q != "" {
		filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename"
		searchColumns := []string{filepathColumn}
		query.parseQueryString(searchColumns, *q)
	}

	if err := qb.validateFilter(fileFilter); err != nil {
		return nil, err
	}
	filter := qb.makeFilter(ctx, fileFilter)

	if err := query.addFilter(filter); err != nil {
		return nil, err
	}

	qb.setQuerySort(&query, findFilter)
	query.sortAndPagination += getPagination(findFilter)

	result, err := qb.queryGroupedFields(ctx, options, query)
	if err != nil {
		return nil, fmt.Errorf("error querying aggregate fields: %w", err)
	}

	idsResult, err := query.findIDs(ctx)
	if err != nil {
		return nil, fmt.Errorf("error finding IDs: %w", err)
	}

	result.IDs = make([]models.FileID, len(idsResult))
	for i, id := range idsResult {
		result.IDs[i] = models.FileID(id)
	}

	return result, nil
}

func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) {
	if !options.Count {
		// nothing to do - return empty result
		return models.NewFileQueryResult(qb), nil
	}

	aggregateQuery := qb.newQuery()

	if options.Count {
		aggregateQuery.addColumn("COUNT(temp.id) as total")
	}

	const includeSortPagination = false
	aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))

	out := struct {
		Total int
	}{}
	if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
		return nil, err
	}

	ret := models.NewFileQueryResult(qb)
	ret.Count = out.Total

	return ret, nil
}

func (qb *FileStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) {
	if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" {
		return
	}
	sort := findFilter.GetSort("path")

	direction := findFilter.GetDirection()
	switch sort {
	case "path":
		// special handling for path
		query.sortAndPagination += fmt.Sprintf(" ORDER BY folders.path %s, files.basename %[1]s", direction)
	default:
		query.sortAndPagination += getSort(sort, direction, "files")
	}
}

func (qb *FileStore) captionRepository() *captionRepository {
	return &captionRepository{
		repository: repository{
			tx:        qb.tx,
			tableName: videoCaptionsTable,
			idColumn:  fileIDColumn,
		},
	}
}

func (qb *FileStore) GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) {
	return qb.captionRepository().get(ctx, fileID)
}

func (qb *FileStore) UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error {
	return qb.captionRepository().replace(ctx, fileID, captions)
}
