package identitycache

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"sync"
	"time"

	"github.com/containerd/log"
	bolt "go.etcd.io/bbolt"
)

var bboltCacheBucket = []byte("image-identity-cache-v1")

type boltBackend struct {
	db        *bolt.DB
	closeOnce sync.Once
	closeErr  error
}

// NewBoltDBBackend creates a bbolt-backed persistent cache backend.
func NewBoltDBBackend(root string) (Backend, error) {
	if root == "" {
		return NewNopBackend(), nil
	}
	cacheDir := filepath.Join(root, "image")
	if err := os.MkdirAll(cacheDir, 0o700); err != nil {
		return nil, err
	}
	db, err := safeOpen(filepath.Join(cacheDir, "identity-cache.db"), 0o600, &bolt.Options{
		FreelistType: bolt.FreelistMapType,
	})
	if err != nil {
		return nil, err
	}
	b := &boltBackend{db: db}
	if err := b.db.Update(func(tx *bolt.Tx) error {
		_, err := tx.CreateBucketIfNotExists(bboltCacheBucket)
		return err
	}); err != nil {
		_ = db.Close()
		return nil, err
	}
	return b, nil
}

func (b *boltBackend) Load(_ context.Context, cacheKey string, now time.Time) (Entry, bool, error) {
	var (
		entry   Entry
		payload []byte
	)
	err := b.db.View(func(tx *bolt.Tx) error {
		bucket := tx.Bucket(bboltCacheBucket)
		if bucket == nil {
			return nil
		}
		value := bucket.Get([]byte(cacheKey))
		if value == nil {
			return nil
		}
		payload = append([]byte(nil), value...)
		return nil
	})
	if err != nil {
		return Entry{}, false, err
	}
	if len(payload) == 0 {
		return Entry{}, false, nil
	}
	if err := json.Unmarshal(payload, &entry); err != nil {
		_ = b.delete(cacheKey)
		return Entry{}, false, nil
	}
	if now.After(entry.ExpiresAt) {
		return Entry{}, false, nil
	}
	return entry, true, nil
}

func (b *boltBackend) Store(_ context.Context, cacheKey string, entry Entry, _ time.Time) error {
	payload, err := json.Marshal(entry)
	if err != nil {
		return err
	}
	return b.db.Update(func(tx *bolt.Tx) error {
		bucket, err := tx.CreateBucketIfNotExists(bboltCacheBucket)
		if err != nil {
			return err
		}
		return bucket.Put([]byte(cacheKey), payload)
	})
}

func (b *boltBackend) Walk(ctx context.Context, _ time.Time, fn func(cacheKey string, entry Entry) error) error {
	return b.db.View(func(tx *bolt.Tx) error {
		bucket := tx.Bucket(bboltCacheBucket)
		if bucket == nil {
			return nil
		}
		cursor := bucket.Cursor()
		for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
			if err := ctx.Err(); err != nil {
				return err
			}

			var entry Entry
			if err := json.Unmarshal(value, &entry); err != nil {
				continue
			}
			if err := fn(string(key), entry); err != nil {
				return err
			}
		}
		return nil
	})
}

func (b *boltBackend) PruneExpired(_ context.Context, now time.Time) error {
	return b.pruneExpiredEntries(now)
}

func (b *boltBackend) Close() error {
	if b == nil || b.db == nil {
		return nil
	}
	b.closeOnce.Do(func() {
		b.closeErr = b.db.Close()
	})
	return b.closeErr
}

func (b *boltBackend) delete(cacheKey string) error {
	return b.db.Update(func(tx *bolt.Tx) error {
		bucket := tx.Bucket(bboltCacheBucket)
		if bucket == nil {
			return nil
		}
		return bucket.Delete([]byte(cacheKey))
	})
}

func (b *boltBackend) pruneExpiredEntries(now time.Time) error {
	return b.db.Update(func(tx *bolt.Tx) error {
		bucket := tx.Bucket(bboltCacheBucket)
		if bucket == nil {
			return nil
		}
		cursor := bucket.Cursor()
		for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
			var entry Entry
			if err := json.Unmarshal(value, &entry); err != nil || now.After(entry.ExpiresAt) {
				if err := cursor.Delete(); err != nil {
					return err
				}
			}
		}
		return nil
	})
}

// safeOpen opens a bolt database with automatic recovery from corruption.
// If the database file is corrupted, it backs up the corrupted file and creates
// a new empty database.
func safeOpen(dbPath string, mode os.FileMode, opts *bolt.Options) (db *bolt.DB, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("%v", r)
		}
		// If we fail opening the DB, but can read a non-empty file, try resetting it.
		if err != nil && fileHasContent(dbPath) {
			db, err = fallbackOpen(dbPath, mode, opts, err)
		}
	}()
	return openDB(dbPath, mode, opts)
}

func openDB(dbPath string, mode os.FileMode, opts *bolt.Options) (*bolt.DB, error) {
	bdb, err := bolt.Open(dbPath, mode, opts)
	if err != nil {
		return nil, err
	}
	return bdb, nil
}

func fallbackOpen(dbPath string, mode os.FileMode, opts *bolt.Options, openErr error) (*bolt.DB, error) {
	backupPath := dbPath + "." + strconv.FormatInt(time.Now().UnixNano(), 10) + ".bak"
	log.L.Errorf("failed to open moby image identity cache database %s, resetting to empty. "+
		"Old database is backed up to %s. This usually means dockerd crashed or was terminated abruptly, leaving the cache DB corrupted. "+
		"If this keeps happening, please report at https://github.com/moby/moby/issues. original error: %v",
		dbPath, backupPath, openErr)
	if err := os.Rename(dbPath, backupPath); err != nil {
		return nil, fmt.Errorf("failed to rename database file %s to %s: %w", dbPath, backupPath, err)
	}
	// Second open should create a new database; failure here is permanent.
	return openDB(dbPath, mode, opts)
}

func fileHasContent(dbPath string) bool {
	st, err := os.Stat(dbPath)
	return err == nil && st.Size() > 0
}
