package tokens

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"time"

	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/cache"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs"
	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client"
	cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
	oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
	"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
	"go.uber.org/zap"
)

type (
	// Credentials is a bearer token get/put interface.
	Credentials interface {
		GetBox(context.Context, oid.Address) (*accessbox.Box, []object.Attribute, error)
		Put(context.Context, cid.ID, CredentialsParam) (oid.Address, error)
		Update(context.Context, oid.Address, CredentialsParam) (oid.Address, error)
	}

	CredentialsParam struct {
		OwnerID          user.ID
		AccessBox        *accessbox.AccessBox
		Expiration       uint64
		Keys             keys.PublicKeys
		CustomAttributes []object.Attribute
	}

	cred struct {
		key                   *keys.PrivateKey
		frostFS               FrostFS
		cache                 *cache.AccessBoxCache
		removingCheckDuration time.Duration
		log                   *zap.Logger
	}

	Config struct {
		FrostFS                     FrostFS
		Key                         *keys.PrivateKey
		CacheConfig                 *cache.Config
		RemovingCheckAfterDurations time.Duration
	}
)

// PrmObjectCreate groups parameters of objects created by credential tool.
type PrmObjectCreate struct {
	// FrostFS identifier of the object creator.
	Creator user.ID

	// FrostFS container to store the object.
	Container cid.ID

	// File path.
	Filepath string

	// Optional.
	// If provided cred object will be created using crdt approach.
	NewVersionFor *oid.ID

	// Last FrostFS epoch of the object lifetime.
	ExpirationEpoch uint64

	// Object payload.
	Payload []byte

	// CustomAttributes are additional user provided attributes for box object.
	CustomAttributes []object.Attribute
}

// FrostFS represents virtual connection to FrostFS network.
type FrostFS interface {
	// CreateObject creates and saves a parameterized object in the specified
	// FrostFS container from a specific user. It sets 'Timestamp' attribute to the current time.
	// It returns the ID of the saved object.
	//
	// It returns exactly one non-nil value. It returns any error encountered which
	// prevented the object from being created.
	CreateObject(context.Context, PrmObjectCreate) (oid.ID, error)

	// GetCredsObject gets the credential object from FrostFS network.
	// It uses search by system name and select using CRDT 2PSet. In case of absence CRDT header
	// it heads object by address.
	//
	// It returns exactly one non-nil value. It returns any error encountered which
	// prevented the object payload from being read.
	GetCredsObject(context.Context, oid.Address) (*object.Object, error)
}

var (
	// ErrEmptyPublicKeys is returned when no HCS keys are provided.
	ErrEmptyPublicKeys = errors.New("HCS public keys could not be empty")
	// ErrEmptyBearerToken is returned when no bearer token is provided.
	ErrEmptyBearerToken = errors.New("Bearer token could not be empty")
)

var _ Credentials = (*cred)(nil)

// New creates a new Credentials instance using the given cli and key.
func New(cfg Config) Credentials {
	return &cred{
		frostFS:               cfg.FrostFS,
		key:                   cfg.Key,
		cache:                 cache.NewAccessBoxCache(cfg.CacheConfig),
		removingCheckDuration: cfg.RemovingCheckAfterDurations,
		log:                   cfg.CacheConfig.Logger,
	}
}

func (c *cred) GetBox(ctx context.Context, addr oid.Address) (*accessbox.Box, []object.Attribute, error) {
	cachedBoxValue := c.cache.Get(addr)
	if cachedBoxValue != nil {
		return c.checkIfCredentialsAreRemoved(ctx, addr, cachedBoxValue)
	}

	box, attrs, err := c.getAccessBox(ctx, addr)
	if err != nil {
		return nil, nil, fmt.Errorf("get access box: %w", err)
	}

	cachedBox, err := box.GetBox(c.key)
	if err != nil {
		return nil, nil, fmt.Errorf("get gate box: %w", err)
	}

	c.putBoxToCache(addr, cachedBox, attrs)

	return cachedBox, attrs, nil
}

func (c *cred) checkIfCredentialsAreRemoved(ctx context.Context, addr oid.Address, cachedBoxValue *cache.AccessBoxCacheValue) (*accessbox.Box, []object.Attribute, error) {
	if time.Since(cachedBoxValue.PutTime) < c.removingCheckDuration {
		return cachedBoxValue.Box, cachedBoxValue.Attributes, nil
	}

	box, attrs, err := c.getAccessBox(ctx, addr)
	if err != nil {
		if client.IsErrObjectAlreadyRemoved(err) {
			c.cache.Delete(addr)
			return nil, nil, fmt.Errorf("get access box: %w", err)
		}
		return cachedBoxValue.Box, cachedBoxValue.Attributes, nil
	}

	cachedBox, err := box.GetBox(c.key)
	if err != nil {
		c.cache.Delete(addr)
		return nil, nil, fmt.Errorf("get gate box: %w", err)
	}
	// we need this to reset PutTime
	// to don't check for removing each time after removingCheckDuration interval
	c.putBoxToCache(addr, cachedBox, attrs)

	return cachedBoxValue.Box, attrs, nil
}

func (c *cred) putBoxToCache(addr oid.Address, box *accessbox.Box, attrs []object.Attribute) {
	if err := c.cache.Put(addr, box, attrs); err != nil {
		c.log.Warn(logs.CouldntPutAccessBoxIntoCache, zap.String("address", addr.EncodeToString()))
	}
}

func (c *cred) getAccessBox(ctx context.Context, addr oid.Address) (*accessbox.AccessBox, []object.Attribute, error) {
	obj, err := c.frostFS.GetCredsObject(ctx, addr)
	if err != nil {
		return nil, nil, fmt.Errorf("read payload and attributes: %w", err)
	}

	// decode access box
	var box accessbox.AccessBox
	if err = box.Unmarshal(obj.Payload()); err != nil {
		return nil, nil, fmt.Errorf("unmarhal access box: %w", err)
	}

	return &box, obj.Attributes(), nil
}

func (c *cred) Put(ctx context.Context, idCnr cid.ID, prm CredentialsParam) (oid.Address, error) {
	return c.createObject(ctx, idCnr, nil, prm)
}

func (c *cred) Update(ctx context.Context, addr oid.Address, prm CredentialsParam) (oid.Address, error) {
	objID := addr.Object()
	return c.createObject(ctx, addr.Container(), &objID, prm)
}

func (c *cred) createObject(ctx context.Context, cnrID cid.ID, newVersionFor *oid.ID, prm CredentialsParam) (oid.Address, error) {
	if len(prm.Keys) == 0 {
		return oid.Address{}, ErrEmptyPublicKeys
	} else if prm.AccessBox == nil {
		return oid.Address{}, ErrEmptyBearerToken
	}
	data, err := prm.AccessBox.Marshal()
	if err != nil {
		return oid.Address{}, fmt.Errorf("marshall box: %w", err)
	}

	idObj, err := c.frostFS.CreateObject(ctx, PrmObjectCreate{
		Creator:          prm.OwnerID,
		Container:        cnrID,
		Filepath:         strconv.FormatInt(time.Now().Unix(), 10) + "_access.box",
		ExpirationEpoch:  prm.Expiration,
		NewVersionFor:    newVersionFor,
		Payload:          data,
		CustomAttributes: prm.CustomAttributes,
	})
	if err != nil {
		return oid.Address{}, fmt.Errorf("create object: %w", err)
	}

	var addr oid.Address
	addr.SetObject(idObj)
	addr.SetContainer(cnrID)

	return addr, nil
}