🍯 Glaze

package store

import (
	"context"
	"database/sql"
	"fmt"
	"io/ioutil"
	"os"
	"path"
	"path/filepath"
	"strings"

	// For SQLite support.
	_ "github.com/glebarez/go-sqlite"
)

// ImageRecord holds all data regarding an image.
type ImageRecord struct {
	UUID         string
	Filename     string
	OrigFilename string
	Thumbname    string
	IsHidden     bool
	Tags         []string
	Timestamp    int64
	Title        string
	Description  string
}

// ImageTag is a tag with related attributes.
type ImageTag struct {
	Name            string
	Hidden          bool
	HiddenThumbnail string
	PreviewEnabled  bool
}

// ReadProvider specifies the necessary structure to read from a store.
type ReadProvider interface {
	GetNumImageRecords(context.Context, ...string) (int, error)
	GetImageRecords(context.Context, int, int, ...string) ([]ImageRecord, error)
	GetImageRecord(
		context.Context, string, ...string,
	) (ImageRecord, string, string, error)
	GetTags(context.Context) ([]ImageTag, error)
}

// WriteProvider specifies the necessary structure to write to a store.
type WriteProvider interface {
	StoreImageRecords(context.Context, []ImageRecord) error
}

// SQLiteOption is the SQLiteProvider option func.
type SQLiteOption func(*SQLiteProvider)

// WithSchemaFile specifies what schemafile to use when setting up an empty db.
func WithSchemaFile(file string) SQLiteOption {
	return func(s *SQLiteProvider) {
		s.schemaFile = file
	}
}

// SQLiteProvider connects to and handles SQLite calls.
type SQLiteProvider struct {
	db         *sql.DB
	schemaFile string
}

// NewSQLiteProvider creates a new SQLite provider.
func NewSQLiteProvider(
	databaseFile string, options ...SQLiteOption,
) (*SQLiteProvider, error) {
	var s SQLiteProvider

	for _, option := range options {
		option(&s)
	}

	databaseDir := filepath.Dir(databaseFile)
	if info, err := os.Stat(databaseDir); os.IsNotExist(err) {
		if err := os.Mkdir(databaseDir, 0750); err != nil {
			return nil, fmt.Errorf("database directory could not be created: %w", err)
		}
	} else if err == nil && !info.IsDir() {
		return nil, fmt.Errorf(
			"could not set up database, path exists but parent is not a folder",
		)
	}

	var err error
	s.db, err = sql.Open("sqlite", databaseFile)
	if err != nil {
		return nil, fmt.Errorf("could not open database: %w", err)
	}
	if _, err := os.Stat(databaseFile); os.IsNotExist(err) {
		if s.schemaFile == "" {
			return nil, fmt.Errorf("empty database and no schemafile defined")
		}

		schema, err := ioutil.ReadFile(s.schemaFile)
		if err != nil {
			return nil, fmt.Errorf(
				"no database and schema file is missing/corrupt: %w", err,
			)
		}

		_, err = s.db.Exec(string(schema))
		if err != nil {
			return nil, fmt.Errorf("could not execute schema: %w", err)
		}
	}
	return &s, nil
}

// Close closes the database connection.
func (p *SQLiteProvider) Close() {
	p.db.Close()
}

func stringInSlice(str string, slice []string) bool {
	for _, s := range slice {
		if str == s {
			return true
		}
	}
	return false
}

// Makes sure all of source is in target.
func sliceInSlice(source, target []string) bool {
	var count int
	for _, s := range source {
		if stringInSlice(s, target) {
			count++
		}
	}
	return count == len(source)
}

// GetNumImageRecords does a simple image count lookup.
func (p *SQLiteProvider) GetNumImageRecords(
	ctx context.Context, tags ...string,
) (int, error) {
	var row *sql.Row
	if len(tags) == 0 {
		row = p.db.QueryRowContext(
			ctx,
			`SELECT COUNT(*) FROM
				(
					SELECT i.id
					FROM images AS i
					INNER JOIN imagetags AS it ON i.id=it.image_id
					INNER JOIN tags AS t ON t.id=it.tag_id
					GROUP BY i.id
				)`,
		)
	} else {
		row = p.db.QueryRowContext(
			ctx,
			`SELECT COUNT(*) FROM
				(
					SELECT i.filename, GROUP_CONCAT(t.name, ',')
					FROM images AS i
					INNER JOIN imagetags AS it ON i.id=it.image_id
					INNER JOIN tags AS t ON t.id=it.tag_id
					WHERE t.name IN ('`+strings.Join(tags, "','")+`')
					GROUP BY i.id
				)`,
		)
	}

	var count int
	if err := row.Scan(&count); err != nil {
		return 0, fmt.Errorf("failed when getting count: %w", err)
	}
	return count, nil
}

// GetImageRecords gets all images that optionally matches tags.
func (p *SQLiteProvider) GetImageRecords(
	ctx context.Context, offset, count int, tags ...string,
) ([]ImageRecord, error) {
	var rows *sql.Rows
	var err error

	allTags, err := p.GetTags(ctx)
	if err != nil {
		return nil, err
	}

	if len(tags) == 0 {
		rows, err = p.db.QueryContext(
			ctx,
			`SELECT i.uuid, i.filename, i.orig_filename, i.timestamp, GROUP_CONCAT(t.name, ','), IFNULL(md.title, ""), IFNULL(md.description, "")
				FROM images AS i
				LEFT JOIN metadata AS md ON i.id=md.image_id
				INNER JOIN imagetags AS it ON i.id=it.image_id
				INNER JOIN tags AS t ON t.id=it.tag_id
				GROUP BY i.id ORDER BY timestamp DESC
				LIMIT ? OFFSET ?`,
			count, offset,
		)
	} else {
		rows, err = p.db.QueryContext(
			ctx, `SELECT i.uuid, i.filename, i.orig_filename, i.timestamp, GROUP_CONCAT(t.name, ','), IFNULL(md.title, ""), IFNULL(md.description, "")
				FROM images AS i
				LEFT JOIN metadata AS md ON i.id=md.image_id
				INNER JOIN imagetags AS it ON i.id=it.image_id
				INNER JOIN tags AS t ON t.id=it.tag_id
				WHERE i.id IN (
					SELECT itt.image_id FROM imagetags AS itt
					INNER JOIN tags AS tt ON tt.id=itt.tag_id
					WHERE i.id=itt.image_id
					AND tt.name IN ('`+strings.Join(tags, "','")+`')
				)
				GROUP BY i.id ORDER BY timestamp DESC
				LIMIT ? OFFSET ?`, count, offset,
		)
	}
	if err != nil {
		return nil, fmt.Errorf("could not query image list from db: %w", err)
	}
	defer rows.Close()

	imageRecords := make([]ImageRecord, 0, 100)
	for rows.Next() {
		var timestamp int64
		var uuid, filename, origFilename, tag, title, description string
		if err := rows.Scan(&uuid, &filename, &origFilename, ×tamp, &tag, &title, &description); err != nil {
			return nil, fmt.Errorf("could not scan image list from db: %w", err)
		}

		tagNames := strings.Split(tag, ",")
		if len(tags) > 0 && !sliceInSlice(tags, tagNames) {
			continue
		}

		var isHidden bool
	skip:
		for _, tag := range tagNames {
			for i := range allTags {
				if allTags[i].Hidden && tag == allTags[i].Name {
					if !stringInSlice(tag, tags) {
						isHidden = true
					}
					break skip
				}
			}
		}

		thumbname := strings.TrimSuffix(
			filename, filepath.Ext(filename),
		) + "_th.jpg"
		imageRecords = append(imageRecords, ImageRecord{
			UUID:         uuid,
			Filename:     filename,
			OrigFilename: origFilename,
			Thumbname:    thumbname,
			IsHidden:     isHidden,
			Tags:         tagNames,
			Timestamp:    timestamp,
			Title:        title,
			Description:  description,
		})
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("could not scan rows from db: %w", err)
	}

	return imageRecords, nil
}

// GetImageRecord fetches all data for a single image, and next/prev uuids.
func (p *SQLiteProvider) GetImageRecord(
	ctx context.Context, uuid string, tags ...string,
) (ImageRecord, string, string, error) {
	var record ImageRecord
	var prev, next string

	var rows *sql.Rows
	var err error

	row := p.db.QueryRowContext(
		ctx,
		`SELECT i.id, i.filename, i.orig_filename, i.timestamp, GROUP_CONCAT(t.name, ','), IFNULL(md.title, ""), IFNULL(md.description, "")
				FROM images AS i
				LEFT JOIN metadata AS md ON i.id=md.image_id
				INNER JOIN imagetags AS it ON i.id=it.image_id
				INNER JOIN tags AS t ON t.id=it.tag_id
				WHERE i.uuid=?
				GROUP BY i.id ORDER BY timestamp DESC`,
		uuid,
	)
	var imageID int
	var timestamp int64
	var filename, origFilename, tag, title, description string
	if err := row.Scan(&imageID, &filename, &origFilename, ×tamp, &tag, &title, &description); err != nil {
		return ImageRecord{}, "", "",
			fmt.Errorf("could not scan image from db: %w", err)
	}
	record.Filename = filename
	record.OrigFilename = filepath.Base(origFilename)
	record.UUID = uuid
	record.Tags = strings.Split(tag, ",")
	record.Timestamp = timestamp
	record.Title = title
	record.Description = description

	if len(tags) == 0 {
		rows, err = p.db.QueryContext(
			ctx,
			`SELECT i.id, i.filename
				FROM images AS i
				INNER JOIN imagetags AS it ON i.id=it.image_id
				INNER JOIN tags AS t ON t.id=it.tag_id
				GROUP BY i.id ORDER BY timestamp DESC`,
		)
	} else {
		rows, err = p.db.QueryContext(
			ctx, `SELECT i.id, i.filename
				FROM images AS i
				INNER JOIN imagetags AS it ON i.id=it.image_id
				INNER JOIN tags AS t ON t.id=it.tag_id
				WHERE i.id IN (
					SELECT itt.image_id FROM imagetags AS itt
					INNER JOIN tags AS tt ON tt.id=itt.tag_id
					WHERE i.id=itt.image_id
					AND tt.name IN ('`+strings.Join(tags, "','")+`')
				)
				GROUP BY i.id ORDER BY timestamp DESC`,
		)
	}
	if err != nil {
		return ImageRecord{}, "", "",
			fmt.Errorf("could not query image list from db: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var id int
		var filename string
		if err := rows.Scan(&id, &filename); err != nil {
			return ImageRecord{}, "", "",
				fmt.Errorf("could not scan image list from db: %w", err)
		}

		if id == imageID {
			if rows.Next() {
				if err := rows.Scan(&id, &next); err != nil {
					return ImageRecord{}, "", "",
						fmt.Errorf("could not scan next image from db: %w", err)
				}
			}
			break
		}
		prev = filename
	}

	return record, prev, next, nil
}

// GetTags fetches all the tags from the database.
func (p *SQLiteProvider) GetTags(ctx context.Context) ([]ImageTag, error) {
	rows, err := p.db.QueryContext(ctx, "SELECT name, hidden, hidden_thumbnail, preview_enabled FROM tags")
	if err != nil {
		return nil, fmt.Errorf("could not query image list from db: %w", err)
	}
	defer rows.Close()

	tags := make([]ImageTag, 0, 10)
	for rows.Next() {
		var name string
		var hidden bool
		var thumbnail string
		var preview bool
		if err := rows.Scan(&name, &hidden, &thumbnail, &preview); err != nil {
			return nil, fmt.Errorf("could not get tags from db: %w", err)
		}

		tags = append(tags, ImageTag{
			Name:            name,
			Hidden:          hidden,
			HiddenThumbnail: thumbnail,
			PreviewEnabled:  preview,
		})
	}
	return tags, nil
}

// StoreImageRecords stores images to the sqlite store.
// TODO: Call-specific data type instead of ImageRecord.
func (p *SQLiteProvider) StoreImageRecords(
	ctx context.Context, records []ImageRecord,
) error {
	tags := make(map[string]int64)
	for _, record := range records {
		for _, tag := range record.Tags {
			if _, ok := tags[tag]; ok {
				continue
			}

			var id int64
			row := p.db.QueryRowContext(ctx, "SELECT id FROM tags WHERE name=?", tag)
			if err := row.Scan(&id); err != nil {
				if err != sql.ErrNoRows {
					return fmt.Errorf("could not look up tag %s: %w", tag, err)
				}

				result, err := p.db.ExecContext(
					ctx, "INSERT INTO tags (name, hidden) VALUES(?, false)", tag,
				)
				if err != nil {
					return fmt.Errorf("could not find or create tag %s: %w", tag, err)
				}

				id, _ = result.LastInsertId()
			}

			tags[tag] = id
		}
	}

	for _, record := range records {
		result, err := p.db.ExecContext(
			ctx,
			`INSERT INTO images (uuid, filename, orig_filename, timestamp)
			VALUES (?, ?, ?, ?)`,
			record.UUID, path.Base(record.Filename), path.Base(record.OrigFilename),
			record.Timestamp,
		)
		if err != nil {
			return fmt.Errorf("could not execute store query: %w", err)
		}

		imageID, _ := result.LastInsertId()
		for _, tag := range record.Tags {
			tagID := tags[tag]
			_, err := p.db.ExecContext(
				ctx, "INSERT INTO imagetags (image_id, tag_id) VALUES(?, ?)",
				imageID, tagID,
			)
			if err != nil {
				return fmt.Errorf(
					"could not link image %d with tag %d: %w", imageID, tagID, err,
				)
			}
		}

		_, err = p.db.ExecContext(
			ctx, "INSERT INTO metadata (image_id, title, description) VALUES(?, ?, ?)",
			imageID, record.Title, record.Description,
		)
		if err != nil {
			return fmt.Errorf(
				"could not link image %d with metadata %s, %s: %w", imageID, record.Title, record.Description, err,
			)
		}
	}

	return nil
}