// Copyright (C) 2018 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.

package backend

import (
	"fmt"
	"os"
	"strconv"
	"strings"

	"github.com/syndtr/goleveldb/leveldb"
	"github.com/syndtr/goleveldb/leveldb/errors"
	"github.com/syndtr/goleveldb/leveldb/opt"
	"github.com/syndtr/goleveldb/leveldb/storage"
	"github.com/syndtr/goleveldb/leveldb/util"
)

const (
	dbMaxOpenFiles = 100

	// A large database is > 200 MiB. It's a mostly arbitrary value, but
	// it's also the case that each file is 2 MiB by default and when we
	// have dbMaxOpenFiles of them we will need to start thrashing fd:s.
	// Switching to large database settings causes larger files to be used
	// when compacting, reducing the number.
	dbLargeThreshold = dbMaxOpenFiles * (2 << MiB)

	KiB = 10
	MiB = 20
)

// OpenLevelDB attempts to open the database at the given location, and runs
// recovery on it if opening fails. Worst case, if recovery is not possible,
// the database is erased and created from scratch.
func OpenLevelDB(location string, tuning Tuning) (Backend, error) {
	opts := optsFor(location, tuning)
	ldb, err := open(location, opts)
	if err != nil {
		return nil, err
	}
	return newLeveldbBackend(ldb, location), nil
}

// OpenLevelDBAuto is OpenLevelDB with TuningAuto tuning.
func OpenLevelDBAuto(location string) (Backend, error) {
	return OpenLevelDB(location, TuningAuto)
}

// OpenLevelDBRO attempts to open the database at the given location, read
// only.
func OpenLevelDBRO(location string) (Backend, error) {
	opts := &opt.Options{
		OpenFilesCacheCapacity: dbMaxOpenFiles,
		ReadOnly:               true,
	}
	ldb, err := open(location, opts)
	if err != nil {
		return nil, err
	}
	return newLeveldbBackend(ldb, location), nil
}

// OpenLevelDBMemory returns a new Backend referencing an in-memory database.
func OpenLevelDBMemory() Backend {
	ldb, _ := leveldb.Open(storage.NewMemStorage(), nil)
	return newLeveldbBackend(ldb, "")
}

// optsFor returns the database options to use when opening a database with
// the given location and tuning. Settings can be overridden by debug
// environment variables.
func optsFor(location string, tuning Tuning) *opt.Options {
	large := false
	switch tuning {
	case TuningLarge:
		large = true
	case TuningAuto:
		large = dbIsLarge(location)
	}

	var (
		// Set defaults used for small databases.
		defaultBlockCacheCapacity            = 0 // 0 means let leveldb use default
		defaultBlockSize                     = 0
		defaultCompactionTableSize           = 0
		defaultCompactionTableSizeMultiplier = 0
		defaultWriteBuffer                   = 16 << MiB                      // increased from leveldb default of 4 MiB
		defaultCompactionL0Trigger           = opt.DefaultCompactionL0Trigger // explicit because we use it as base for other stuff
	)

	if large {
		// Change the parameters for better throughput at the price of some
		// RAM and larger files. This results in larger batches of writes
		// and compaction at a lower frequency.
		l.Infoln("Using large-database tuning")

		defaultBlockCacheCapacity = 64 << MiB
		defaultBlockSize = 64 << KiB
		defaultCompactionTableSize = 16 << MiB
		defaultCompactionTableSizeMultiplier = 20 // 2.0 after division by ten
		defaultWriteBuffer = 64 << MiB
		defaultCompactionL0Trigger = 8 // number of l0 files
	}

	opts := &opt.Options{
		BlockCacheCapacity:            debugEnvValue("BlockCacheCapacity", defaultBlockCacheCapacity),
		BlockCacheEvictRemoved:        debugEnvValue("BlockCacheEvictRemoved", 0) != 0,
		BlockRestartInterval:          debugEnvValue("BlockRestartInterval", 0),
		BlockSize:                     debugEnvValue("BlockSize", defaultBlockSize),
		CompactionExpandLimitFactor:   debugEnvValue("CompactionExpandLimitFactor", 0),
		CompactionGPOverlapsFactor:    debugEnvValue("CompactionGPOverlapsFactor", 0),
		CompactionL0Trigger:           debugEnvValue("CompactionL0Trigger", defaultCompactionL0Trigger),
		CompactionSourceLimitFactor:   debugEnvValue("CompactionSourceLimitFactor", 0),
		CompactionTableSize:           debugEnvValue("CompactionTableSize", defaultCompactionTableSize),
		CompactionTableSizeMultiplier: float64(debugEnvValue("CompactionTableSizeMultiplier", defaultCompactionTableSizeMultiplier)) / 10.0,
		CompactionTotalSize:           debugEnvValue("CompactionTotalSize", 0),
		CompactionTotalSizeMultiplier: float64(debugEnvValue("CompactionTotalSizeMultiplier", 0)) / 10.0,
		DisableBufferPool:             debugEnvValue("DisableBufferPool", 0) != 0,
		DisableBlockCache:             debugEnvValue("DisableBlockCache", 0) != 0,
		DisableCompactionBackoff:      debugEnvValue("DisableCompactionBackoff", 0) != 0,
		DisableLargeBatchTransaction:  debugEnvValue("DisableLargeBatchTransaction", 0) != 0,
		NoSync:                        debugEnvValue("NoSync", 0) != 0,
		NoWriteMerge:                  debugEnvValue("NoWriteMerge", 0) != 0,
		OpenFilesCacheCapacity:        debugEnvValue("OpenFilesCacheCapacity", dbMaxOpenFiles),
		WriteBuffer:                   debugEnvValue("WriteBuffer", defaultWriteBuffer),
		// The write slowdown and pause can be overridden, but even if they
		// are not and the compaction trigger is overridden we need to
		// adjust so that we don't pause writes for L0 compaction before we
		// even *start* L0 compaction...
		WriteL0SlowdownTrigger: debugEnvValue("WriteL0SlowdownTrigger", 2*debugEnvValue("CompactionL0Trigger", defaultCompactionL0Trigger)),
		WriteL0PauseTrigger:    debugEnvValue("WriteL0SlowdownTrigger", 3*debugEnvValue("CompactionL0Trigger", defaultCompactionL0Trigger)),
	}

	return opts
}

func open(location string, opts *opt.Options) (*leveldb.DB, error) {
	db, err := leveldb.OpenFile(location, opts)
	if leveldbIsCorrupted(err) {
		db, err = leveldb.RecoverFile(location, opts)
	}
	if leveldbIsCorrupted(err) {
		// The database is corrupted, and we've tried to recover it but it
		// didn't work. At this point there isn't much to do beyond dropping
		// the database and reindexing...
		l.Infoln("Database corruption detected, unable to recover. Reinitializing...")
		if err := os.RemoveAll(location); err != nil {
			return nil, &errorSuggestion{err, "failed to delete corrupted database"}
		}
		db, err = leveldb.OpenFile(location, opts)
	}
	if err != nil {
		return nil, &errorSuggestion{err, "is another instance of Syncthing running?"}
	}

	if debugEnvValue("CompactEverything", 0) != 0 {
		if err := db.CompactRange(util.Range{}); err != nil {
			l.Warnln("Compacting database:", err)
		}
	}

	return db, nil
}

func debugEnvValue(key string, def int) int {
	v, err := strconv.ParseInt(os.Getenv("STDEBUG_"+key), 10, 63)
	if err != nil {
		return def
	}
	return int(v)
}

// A "better" version of leveldb's errors.IsCorrupted.
func leveldbIsCorrupted(err error) bool {
	switch {
	case err == nil:
		return false

	case errors.IsCorrupted(err):
		return true

	case strings.Contains(err.Error(), "corrupted"):
		return true
	}

	return false
}

// dbIsLarge returns whether the estimated size of the database at location
// is large enough to warrant optimization for large databases.
func dbIsLarge(location string) bool {
	if ^uint(0)>>63 == 0 {
		// We're compiled for a 32 bit architecture. We've seen trouble with
		// large settings there.
		// (https://forum.syncthing.net/t/many-small-ldb-files-with-database-tuning/13842)
		return false
	}

	entries, err := os.ReadDir(location)
	if err != nil {
		return false
	}

	var size int64
	for _, entry := range entries {
		if entry.Name() == "LOG" {
			// don't count the size
			continue
		}
		fi, err := entry.Info()
		if err != nil {
			continue
		}
		size += fi.Size()
	}

	return size > dbLargeThreshold
}

type errorSuggestion struct {
	inner      error
	suggestion string
}

func (e *errorSuggestion) Error() string {
	return fmt.Sprintf("%s (%s)", e.inner.Error(), e.suggestion)
}
