package main

import (
	"context"
	"crypto/rand"
	"database/sql"
	"errors"
	"fmt"
	"math/big"
	"os"
	"path/filepath"
	"regexp"
	"time"
	"unicode/utf8"

	_ "github.com/mattn/go-sqlite3"
)

const (
	maximumCodeBytes  = 200 * 1024
	maximumTitleRunes = 80
)

var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`)

type playground struct {
	ID        string    `json:"id"`
	Revision  int       `json:"revision"`
	Title     string    `json:"title"`
	Code      string    `json:"code"`
	CreatedAt time.Time `json:"createdAt"`
}

type playgroundStore struct {
	*sql.DB
}

func openPlaygroundStore(path string) (*playgroundStore, error) {
	if path != ":memory:" {
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			return nil, err
		}
	}
	database, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL")
	if err != nil {
		return nil, err
	}
	database.SetMaxOpenConns(1)
	store := &playgroundStore{DB: database}
	if err := store.initialize(); err != nil {
		database.Close()
		return nil, err
	}
	return store, nil
}

func (store *playgroundStore) initialize() error {
	_, err := store.Exec(`
		CREATE TABLE IF NOT EXISTS playgrounds (
			id TEXT NOT NULL,
			revision INTEGER NOT NULL CHECK (revision > 0),
			title TEXT NOT NULL,
			code TEXT NOT NULL,
			created_at TEXT NOT NULL,
			PRIMARY KEY (id, revision)
		);
		CREATE INDEX IF NOT EXISTS playgrounds_created_at
			ON playgrounds (created_at DESC);
	`)
	return err
}

func (store *playgroundStore) save(ctx context.Context, id, title, code string) (playground, error) {
	if id != "" && !playgroundIDPattern.MatchString(id) {
		return playground{}, errors.New("invalid playground id")
	}
	if title == "" || utf8.RuneCountInString(title) > maximumTitleRunes {
		return playground{}, fmt.Errorf("title must contain 1 to %d characters", maximumTitleRunes)
	}
	if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) {
		return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes)
	}
	if id == "" {
		var err error
		id, err = randomPlaygroundID()
		if err != nil {
			return playground{}, err
		}
	}

	transaction, err := store.BeginTx(ctx, nil)
	if err != nil {
		return playground{}, err
	}
	defer transaction.Rollback()

	var revision int
	if err := transaction.QueryRowContext(
		ctx,
		`SELECT COALESCE(MAX(revision), 0) + 1 FROM playgrounds WHERE id = ?`,
		id,
	).Scan(&revision); err != nil {
		return playground{}, err
	}
	createdAt := time.Now().UTC().Truncate(time.Millisecond)
	if _, err := transaction.ExecContext(
		ctx,
		`INSERT INTO playgrounds (id, revision, title, code, created_at) VALUES (?, ?, ?, ?, ?)`,
		id,
		revision,
		title,
		code,
		createdAt.Format(time.RFC3339Nano),
	); err != nil {
		return playground{}, err
	}
	if err := transaction.Commit(); err != nil {
		return playground{}, err
	}
	return playground{ID: id, Revision: revision, Title: title, Code: code, CreatedAt: createdAt}, nil
}

func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) {
	if !playgroundIDPattern.MatchString(id) || revision < 1 {
		return playground{}, sql.ErrNoRows
	}
	var result playground
	var createdAt string
	err := store.QueryRowContext(
		ctx,
		`SELECT id, revision, title, code, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
		id,
		revision,
	).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &createdAt)
	if err != nil {
		return playground{}, err
	}
	result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
	return result, err
}

func randomPlaygroundID() (string, error) {
	const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"
	result := make([]byte, 10)
	for index := range result {
		value, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
		if err != nil {
			return "", err
		}
		result[index] = alphabet[value.Int64()]
	}
	return string(result), nil
}
