package db

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/pkg/errors"

	bolt "go.etcd.io/bbolt"

	gobmarsh "gitea.hashru.nl/dsprenkels/rushlink/pkg/gobmarsh"
)

// Database is the main rushlink database type.
//
// Open a database using DB.Open() and close it in the end using DB.Close().
// Only one instance of DB should exist in a program at any moment.
type Database struct {
	Bolt *bolt.DB
}

// CurrentMigrateVersion holds the current "migrate version".
//
// If we alter the database format, we bump this number and write a new
// database migration in migrate().
const CurrentMigrateVersion = 3

// BucketConf holds the name for the "configuration" bucket.
//
// This bucket holds the database version, secret site-wide keys, etc.
const BucketConf = "conf"

// BucketPastes holds the name for the pastes bucket.
const BucketPastes = "pastes"

// BucketFileUpload holds the name for the file-upload bucket.
const BucketFileUpload = "fileUpload"

// KeyMigrateVersion stores the current migration version. If this value is less than
// CurrentMigrateVersion, the database has to be migrated.
const KeyMigrateVersion = "migrate_version"

// OpenDB opens a database file located at path.
func OpenDB(path string, fs *FileStore) (*Database, error) {
	if path == "" {
		return nil, errors.New("database not set")
	}

	db, err := bolt.Open(path, 0666, &bolt.Options{Timeout: 1 * time.Second})
	if err != nil {
		return nil, errors.Wrapf(err, "failed to open database at '%v'", path)
	}
	if err := db.Update(func(tx *bolt.Tx) error { return migrate(tx, fs) }); err != nil {
		return nil, err
	}
	return &Database{db}, nil
}

// Close the bolt database
func (db *Database) Close() error {
	if db == nil {
		panic("no open database")
	}
	return db.Close()
}

// Initialize and migrate the database to the current version
func migrate(tx *bolt.Tx, fs *FileStore) error {
	// Guidelines for error handling:
	//   - Errors based on malformed *structure* should be fatal!
	//   - Errors based on malformed *data* should print a warning
	//     (and if possible try to fix the error).

	dbVersion, err := dbVersion(tx)
	if err != nil {
		return err
	}

	// Migrate the database to version 1
	if dbVersion < 1 {
		log.Println("migrating database to version 1")
		// Create conf bucket
		_, err := tx.CreateBucket([]byte(BucketConf))
		if err != nil {
			return err
		}
		// Create paste bucket
		_, err = tx.CreateBucket([]byte(BucketPastes))
		if err != nil {
			return err
		}
		// Update the version number
		if err := setDBVersion(tx, 1); err != nil {
			return err
		}
	}

	if dbVersion < 2 {
		log.Println("migrating database to version 2")
		// Create fileUpload bucket
		_, err := tx.CreateBucket([]byte(BucketFileUpload))
		if err != nil {
			return err
		}
		// Update the version number
		if err := setDBVersion(tx, 2); err != nil {
			return err
		}
	}

	if dbVersion < 3 {
		log.Println("migrating database to version 3")
		// In this version, we changed te way how Content-Types are being
		// stored.  Previously, we allowed clients to provide their own
		// Content-Types for files, using the Content-Disposition header in
		// multipart forms.  The new way detects these types using
		// http.DetectContentType.
		//
		// Scan through all the FileUploads and update their ContentTypes.
		bucket := tx.Bucket([]byte(BucketFileUpload))
		cursor := bucket.Cursor()
		var id, storedBytes []byte
		id, storedBytes = cursor.First()
		for id != nil {
			fu, err := decodeFileUpload(storedBytes)
			if err != nil {
				log.Print("error: ", errors.Wrapf(err, "corrupted FileUpload in database at '%v'", id))
				id, storedBytes = cursor.Next()
				continue
			}
			if fu.State != FileUploadStatePresent {
				id, storedBytes = cursor.Next()
				continue
			}
			filePath := fu.Path(fs)
			file, err := os.Open(fu.Path(fs))
			if err != nil {
				log.Print("error: ", errors.Wrapf(err, "could not open file at '%v'", filePath))
				id, storedBytes = cursor.Next()
				continue
			}
			var buf bytes.Buffer
			buf.Grow(512)
			io.CopyN(&buf, file, 512)
			contentType := http.DetectContentType(buf.Bytes())
			if contentType != fu.ContentType {
				fu.ContentType = contentType
				fu.Save(tx)
				cursor.Seek(id)
			}
			id, storedBytes = cursor.Next()
		}
		// Update the version number
		if err := setDBVersion(tx, 3); err != nil {
			return err
		}
	}

	return nil
}

// Get the current migrate version from the database
func dbVersion(tx *bolt.Tx) (int, error) {
	conf := tx.Bucket([]byte(BucketConf))
	if conf == nil {
		return 0, nil
	}
	dbVersionBytes := conf.Get([]byte(KeyMigrateVersion))
	if dbVersionBytes == nil {
		return 0, nil
	}

	// Version was already stored
	var dbVersion int
	if err := gobmarsh.Unmarshal(dbVersionBytes, &dbVersion); err != nil {
		return 0, err
	}
	if dbVersion == 0 {
		return 0, fmt.Errorf("database version is invalid (%v)", dbVersion)
	}
	if dbVersion > CurrentMigrateVersion {
		return 0, fmt.Errorf("database version is too recent (%v > %v)", dbVersion, CurrentMigrateVersion)
	}
	return dbVersion, nil
}

// Update the current migrate version in the database
func setDBVersion(tx *bolt.Tx, version int) error {
	conf, err := tx.CreateBucketIfNotExists([]byte(BucketConf))
	if err != nil {
		return err
	}

	versionBytes, err := gobmarsh.Marshal(version)
	if err != nil {
		return err
	}
	return conf.Put([]byte(KeyMigrateVersion), versionBytes)
}