// The code in this file is an adapted version of the bbolt compact command
// implemented in this file:
// https://github.com/etcd-io/bbolt/blob/master/cmd/bbolt/main.go

//go:build !js
// +build !js

package kvdb

import (
	"encoding/hex"
	"fmt"
	"os"
	"path"
	"time"

	"github.com/lightningnetwork/lnd/healthcheck"
	"go.etcd.io/bbolt"
)

const (
	// defaultResultFileSizeMultiplier is the default multiplier we apply to
	// the current database size to calculate how big it could possibly get
	// after compacting, in case the database is already at its optimal size
	// and compaction causes it to grow. This should normally not be the
	// case but we really want to avoid not having enough disk space for the
	// compaction, so we apply a safety margin of 10%.
	defaultResultFileSizeMultiplier = float64(1.1)

	// defaultTxMaxSize is the default maximum number of operations that
	// are allowed to be executed in a single transaction.
	defaultTxMaxSize = 65536

	// bucketFillSize is the fill size setting that is used for each new
	// bucket that is created in the compacted database. This setting is not
	// persisted and is therefore only effective for the compaction itself.
	// Because during the compaction we only append data a fill percent of
	// 100% is optimal for performance.
	bucketFillSize = 1.0
)

type compacter struct {
	srcPath   string
	dstPath   string
	txMaxSize int64

	// dbTimeout specifies the timeout value used when opening the db.
	dbTimeout time.Duration
}

// execute opens the source and destination databases and then compacts the
// source into destination and returns the size of both files as a result.
func (cmd *compacter) execute() (int64, int64, error) {
	if cmd.txMaxSize == 0 {
		cmd.txMaxSize = defaultTxMaxSize
	}

	// Ensure source file exists.
	fi, err := os.Stat(cmd.srcPath)
	if err != nil {
		return 0, 0, fmt.Errorf("error determining source database "+
			"size: %v", err)
	}
	initialSize := fi.Size()
	marginSize := float64(initialSize) * defaultResultFileSizeMultiplier

	// Before opening any of the databases, let's first make sure we have
	// enough free space on the destination file system to create a full
	// copy of the source DB (worst-case scenario if the compaction doesn't
	// actually shrink the file size).
	destFolder := path.Dir(cmd.dstPath)
	freeSpace, err := healthcheck.AvailableDiskSpace(destFolder)
	if err != nil {
		return 0, 0, fmt.Errorf("error determining free disk space on "+
			"%s: %v", destFolder, err)
	}
	log.Debugf("Free disk space on compaction destination file system: "+
		"%d bytes", freeSpace)
	if freeSpace < uint64(marginSize) {
		return 0, 0, fmt.Errorf("could not start compaction, "+
			"destination folder %s only has %d bytes of free disk "+
			"space available while we need at least %d for worst-"+
			"case compaction", destFolder, freeSpace, uint64(marginSize))
	}

	// Open source database. We open it in read only mode to avoid (and fix)
	// possible freelist sync problems.
	src, err := bbolt.Open(cmd.srcPath, 0444, &bbolt.Options{
		ReadOnly: true,
		Timeout:  cmd.dbTimeout,
	})
	if err != nil {
		return 0, 0, fmt.Errorf("error opening source database: %w",
			err)
	}
	defer func() {
		if err := src.Close(); err != nil {
			log.Errorf("Compact error: closing source DB: %v", err)
		}
	}()

	// Open destination database.
	dst, err := bbolt.Open(cmd.dstPath, fi.Mode(), &bbolt.Options{
		Timeout: cmd.dbTimeout,
	})
	if err != nil {
		return 0, 0, fmt.Errorf("error opening destination database: "+
			"%w", err)
	}
	defer func() {
		if err := dst.Close(); err != nil {
			log.Errorf("Compact error: closing dest DB: %v", err)
		}
	}()

	// Run compaction.
	if err := cmd.compact(dst, src); err != nil {
		return 0, 0, fmt.Errorf("error running compaction: %w", err)
	}

	// Report stats on new size.
	fi, err = os.Stat(cmd.dstPath)
	if err != nil {
		return 0, 0, fmt.Errorf("error determining destination "+
			"database size: %w", err)
	} else if fi.Size() == 0 {
		return 0, 0, fmt.Errorf("zero db size")
	}

	return initialSize, fi.Size(), nil
}

// compact tries to create a compacted copy of the source database in a new
// destination database.
func (cmd *compacter) compact(dst, src *bbolt.DB) error {
	// Commit regularly, or we'll run out of memory for large datasets if
	// using one transaction.
	var size int64
	tx, err := dst.Begin(true)
	if err != nil {
		return err
	}
	defer func() {
		_ = tx.Rollback()
	}()

	if err := cmd.walk(src, func(keys [][]byte, k, v []byte, seq uint64) error {
		// On each key/value, check if we have exceeded tx size.
		sz := int64(len(k) + len(v))
		if size+sz > cmd.txMaxSize && cmd.txMaxSize != 0 {
			// Commit previous transaction.
			if err := tx.Commit(); err != nil {
				return err
			}

			// Start new transaction.
			tx, err = dst.Begin(true)
			if err != nil {
				return err
			}
			size = 0
		}
		size += sz

		// Create bucket on the root transaction if this is the first
		// level.
		nk := len(keys)
		if nk == 0 {
			bkt, err := tx.CreateBucket(k)
			if err != nil {
				return err
			}
			if err := bkt.SetSequence(seq); err != nil {
				return err
			}
			return nil
		}

		// Create buckets on subsequent levels, if necessary.
		b := tx.Bucket(keys[0])
		if nk > 1 {
			for _, k := range keys[1:] {
				b = b.Bucket(k)
			}
		}

		// Fill the entire page for best compaction.
		b.FillPercent = bucketFillSize

		// If there is no value then this is a bucket call.
		if v == nil {
			bkt, err := b.CreateBucket(k)
			if err != nil {
				return err
			}
			if err := bkt.SetSequence(seq); err != nil {
				return err
			}
			return nil
		}

		// Otherwise treat it as a key/value pair.
		return b.Put(k, v)
	}); err != nil {
		return err
	}

	return tx.Commit()
}

// walkFunc is the type of the function called for keys (buckets and "normal"
// values) discovered by Walk. keys is the list of keys to descend to the bucket
// owning the discovered key/value pair k/v.
type walkFunc func(keys [][]byte, k, v []byte, seq uint64) error

// walk walks recursively the bolt database db, calling walkFn for each key it
// finds.
func (cmd *compacter) walk(db *bbolt.DB, walkFn walkFunc) error {
	return db.View(func(tx *bbolt.Tx) error {
		return tx.ForEach(func(name []byte, b *bbolt.Bucket) error {
			// This will log the top level buckets only to give the
			// user some sense of progress.
			log.Debugf("Compacting top level bucket '%s'",
				LoggableKeyName(name))

			return cmd.walkBucket(
				b, nil, name, nil, b.Sequence(), walkFn,
			)
		})
	})
}

// LoggableKeyName returns a printable name of the given key.
func LoggableKeyName(key []byte) string {
	strKey := string(key)
	if hasSpecialChars(strKey) {
		return hex.EncodeToString(key)
	}

	return strKey
}

// hasSpecialChars returns true if any of the characters in the given string
// cannot be printed.
func hasSpecialChars(s string) bool {
	for _, b := range s {
		if !(b >= 'a' && b <= 'z') && !(b >= 'A' && b <= 'Z') &&
			!(b >= '0' && b <= '9') && b != '-' && b != '_' {

			return true
		}
	}

	return false
}

// walkBucket recursively walks through a bucket.
func (cmd *compacter) walkBucket(b *bbolt.Bucket, keyPath [][]byte, k, v []byte,
	seq uint64, fn walkFunc) error {

	// Execute callback.
	if err := fn(keyPath, k, v, seq); err != nil {
		return err
	}

	// If this is not a bucket then stop.
	if v != nil {
		return nil
	}

	// Iterate over each child key/value.
	keyPath = append(keyPath, k)
	return b.ForEach(func(k, v []byte) error {
		if v == nil {
			bkt := b.Bucket(k)
			return cmd.walkBucket(
				bkt, keyPath, k, nil, bkt.Sequence(), fn,
			)
		}
		return cmd.walkBucket(b, keyPath, k, v, b.Sequence(), fn)
	})
}
