package object_storage

import (
	"context"
	"errors"
	"fmt"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/s3/types"
	"github.com/aws/smithy-go"
	"io"
	"strings"
	"time"
)

type S3StorageConfig struct {
	Bucket         string
	DefaultLinkTTL time.Duration
}

type s3Storage struct {
	client          *s3.Client
	presignedClient *s3.PresignClient
	config          S3StorageConfig
}

func newS3Storage(c Config) (*s3Storage, error) {
	cfg, ok := c.(S3StorageConfig)
	if !ok {
		return nil, ErrInvalidConfig
	}

	storageConfig, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		return nil, ErrInvalidConfig
	}

	s := &s3Storage{
		client: s3.NewFromConfig(storageConfig),
		config: cfg,
	}

	s.presignedClient = s3.NewPresignClient(s.client)

	return s, nil
}

func (s *s3Storage) PutNewObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...PutOptions) error {
	if err := s.checkInstance(); err != nil {
		return fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
	}

	if len(opts) > 1 {
		return fmt.Errorf("%w: %v", ErrPutNewObject, ErrToManyOptions)
	}

	fullKey := strings.Join([]string{string(category), objectId}, keySeparator)

	if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
		fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
	}

	if _, err := s.exists(ctx, fullKey); err != nil {
		if !errors.Is(err, ErrObjectNotFound) {
			return fmt.Errorf("%w: %v", ErrPutNewObject, err)
		}
	} else {
		return fmt.Errorf("%w: %v", ErrPutNewObject, ErrObjectAlreadyExists)
	}

	putOptions := &s3.PutObjectInput{
		Bucket:            aws.String(s.config.Bucket),
		Key:               aws.String(fullKey),
		Body:              r,
		ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
	}

	if len(opts) != 0 {
		putOptions.ContentType = aws.String(opts[0].ContentType)
	}

	if _, err := s.client.PutObject(ctx, putOptions); err != nil {
		return fmt.Errorf("%w: %v", ErrPutNewObject, err)
	}

	return nil
}

func (s *s3Storage) GetObject(ctx context.Context, objectId string, category Category, opts ...GetOptions) (io.ReadCloser, error) {
	if err := s.checkInstance(); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
	}

	if len(opts) > 1 {
		return nil, fmt.Errorf("%w: %v", ErrGetObject, ErrToManyOptions)
	}

	fullKey := strings.Join([]string{string(category), objectId}, keySeparator)

	if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
		fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
	}

	out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
		Bucket:       aws.String(s.config.Bucket),
		Key:          aws.String(fullKey),
		ChecksumMode: types.ChecksumModeEnabled,
	})
	if err != nil {
		var apiErr smithy.APIError
		if errors.As(err, &apiErr) && apiErr.ErrorCode() == s3ErrorPatternNoSuchKey {
			return nil, fmt.Errorf("%w: %v", ErrGetObject, ErrObjectNotFound)
		}

		return nil, fmt.Errorf("%w: %v", ErrGetObject, err)
	}

	return out.Body, nil
}

func (s *s3Storage) DeleteObject(ctx context.Context, objectId string, category Category, opts ...DeleteOptions) error {
	if err := s.checkInstance(); err != nil {
		return fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
	}

	if len(opts) > 1 {
		return fmt.Errorf("%w: %v", ErrDeleteObject, ErrToManyOptions)
	}

	fullKey := strings.Join([]string{string(category), objectId}, keySeparator)

	if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
		fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
	}

	head, err := s.exists(ctx, fullKey)
	if err != nil {
		return err
	}

	if _, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
		Bucket:  aws.String(s.config.Bucket),
		Key:     aws.String(fullKey),
		IfMatch: head.ETag,
	}); err != nil {
		var apiErr smithy.APIError
		if errors.As(err, &apiErr) {
			return fmt.Errorf("%w: %v", ErrDeleteObject, ErrObjectNotFound)
		}

		return fmt.Errorf("%w: %v", ErrDeleteObject, err)
	}

	return nil
}

func (s *s3Storage) UpdateObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...UpdateOptions) error {
	if err := s.checkInstance(); err != nil {
		return fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
	}

	if len(opts) > 1 {
		return fmt.Errorf("%w: %v", ErrUpdateObject, ErrToManyOptions)
	}

	fullKey := strings.Join([]string{string(category), objectId}, keySeparator)

	if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
		fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
	}

	head, err := s.exists(ctx, fullKey)
	if err != nil {
		return err
	}

	input := &s3.PutObjectInput{
		Bucket:  aws.String(s.config.Bucket),
		Key:     aws.String(fullKey),
		Body:    r,
		IfMatch: head.ETag,
	}

	if len(opts) != 0 && opts[0].ContentType != "" {
		input.ContentType = aws.String(opts[0].ContentType)
	}

	if _, err = s.client.PutObject(ctx, input); err != nil {
		return fmt.Errorf("%w: %v", ErrUpdateObject, err)
	}

	return nil
}

func (s *s3Storage) GetPresignedLink(ctx context.Context, objectId string, category Category, opts ...LinkOptions) (string, error) {
	if err := s.checkInstance(); err != nil {
		return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
	}

	if len(opts) > 1 {
		return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, ErrToManyOptions)
	}

	fullKey := strings.Join([]string{string(category), objectId}, keySeparator)

	if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
		fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
	}

	if _, err := s.exists(ctx, fullKey); err != nil {
		return "", err
	}

	duration := s.config.DefaultLinkTTL

	if len(opts) != 0 && opts[0].TTL != nil {
		duration = *opts[0].TTL
	}

	presigned, err := s.presignedClient.PresignGetObject(ctx, &s3.GetObjectInput{
		Bucket: aws.String(s.config.Bucket),
		Key:    aws.String(fullKey),
	}, s3.WithPresignExpires(duration))
	if err != nil {
		return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
	}

	return presigned.URL, nil
}

func (s *s3Storage) checkInstance() error {
	if s == nil || s.client == nil {
		return ErrInvalidInstance
	}

	return nil
}

func (s *s3Storage) exists(ctx context.Context, fullKey string) (*s3.HeadObjectOutput, error) {
	head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
		Bucket: aws.String(s.config.Bucket),
		Key:    aws.String(fullKey),
	})
	if err != nil {
		var apiErr smithy.APIError
		if errors.As(err, &apiErr) && apiErr.ErrorCode() == s3ErrorPatternNotFound {
			return nil, ErrObjectNotFound
		}

		return nil, fmt.Errorf("%w: %v", ErrCheckExists, err)
	}

	return head, nil
}
