package s3

import (
	"context"
	"errors"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	awsv2 "github.com/aws/aws-sdk-go-v2/aws"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/s3/types"
	"github.com/aws/smithy-go"

	"wucher/internal/config"
	filemanager "wucher/internal/domain/file_manager"
	"wucher/internal/shared/pkg/metrics"
)

type FileObjectStorage struct {
	cfg       config.FileStorageConfig
	client    *awss3.Client
	presigner *awss3.PresignClient
	component string
}

func NewFileObjectStorage(ctx context.Context, cfg config.FileStorageConfig) (*FileObjectStorage, error) {
	if err := validateConfig(cfg); err != nil {
		return nil, err
	}

	loadOptions := make([]func(*awsconfig.LoadOptions) error, 0, 3)
	if cfg.Region != "" {
		loadOptions = append(loadOptions, awsconfig.WithRegion(cfg.Region))
	}
	if strings.TrimSpace(cfg.Endpoint) != "" &&
		strings.TrimSpace(cfg.AccessKeyID) != "" &&
		strings.TrimSpace(cfg.SecretAccessKey) != "" {
		loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
			strings.TrimSpace(cfg.AccessKeyID),
			strings.TrimSpace(cfg.SecretAccessKey),
			"",
		)))
	}
	awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...)
	if err != nil {
		return nil, err
	}

	client := awss3.NewFromConfig(awsCfg, func(options *awss3.Options) {
		options.UsePathStyle = cfg.ForcePathStyle
		if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
			options.BaseEndpoint = awsv2.String(endpoint)
		}
	})

	storage := &FileObjectStorage{
		cfg:       cfg,
		client:    client,
		presigner: awss3.NewPresignClient(client),
		component: "api",
	}

	if cfg.AutoCreateBucket {
		if err := storage.EnsureBucket(ctx); err != nil {
			return nil, err
		}
	}

	return storage, nil
}

func (s *FileObjectStorage) EnsureBucket(ctx context.Context) error {
	headStarted := time.Now()
	if _, err := s.client.HeadBucket(ctx, &awss3.HeadBucketInput{
		Bucket: awsv2.String(s.cfg.Bucket),
	}); err == nil {
		metrics.ObserveAWSRequest(s.component, "s3", "HeadBucket", nil, headStarted)
		return nil
	} else {
		metrics.ObserveAWSRequest(s.component, "s3", "HeadBucket", err, headStarted)
	}

	input := &awss3.CreateBucketInput{
		Bucket: awsv2.String(s.cfg.Bucket),
	}
	if s.cfg.Region != "" && s.cfg.Region != "us-east-1" {
		input.CreateBucketConfiguration = &types.CreateBucketConfiguration{
			LocationConstraint: types.BucketLocationConstraint(s.cfg.Region),
		}
	}

	createStarted := time.Now()
	_, err := s.client.CreateBucket(ctx, input)
	metrics.ObserveAWSRequest(s.component, "s3", "CreateBucket", err, createStarted)
	if err == nil {
		return nil
	}
	if isBucketAlreadyOwnedByYou(err) {
		return nil
	}
	return err
}

func (s *FileObjectStorage) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
	objectKey := buildObjectKey(s.cfg.ObjectKeyPrefix, input.ObjectKey)

	started := time.Now()
	output, err := s.client.PutObject(ctx, &awss3.PutObjectInput{
		Bucket:      awsv2.String(s.cfg.Bucket),
		Key:         awsv2.String(objectKey),
		Body:        input.Body,
		ContentType: optionalString(input.ContentType),
		Metadata:    input.Metadata,
	})
	metrics.ObserveAWSRequest(s.component, "s3", "PutObject", err, started)
	metrics.ObserveFileUpload(s.component, err)
	if err == nil {
		metrics.ObserveAWSTransferredBytes(s.component, "s3", "PutObject", "upload", input.SizeBytes)
	}
	if err != nil {
		return filemanager.ObjectMetadata{}, err
	}

	return filemanager.ObjectMetadata{
		Bucket:    s.cfg.Bucket,
		ObjectKey: objectKey,
		ETag:      awsv2.ToString(output.ETag),
		VersionID: awsv2.ToString(output.VersionId),
	}, nil
}

func (s *FileObjectStorage) CopyObject(ctx context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
	sourceKey := buildObjectKey(s.cfg.ObjectKeyPrefix, sourceObjectKey)
	destinationKey := buildObjectKey(s.cfg.ObjectKeyPrefix, destinationObjectKey)

	started := time.Now()
	_, err := s.client.CopyObject(ctx, &awss3.CopyObjectInput{
		Bucket:            awsv2.String(s.cfg.Bucket),
		CopySource:        awsv2.String(url.PathEscape(s.cfg.Bucket + "/" + sourceKey)),
		Key:               awsv2.String(destinationKey),
		MetadataDirective: types.MetadataDirectiveCopy,
	})
	metrics.ObserveAWSRequest(s.component, "s3", "CopyObject", err, started)
	if err != nil {
		return filemanager.ObjectMetadata{}, err
	}
	return s.HeadObject(ctx, destinationObjectKey)
}

func (s *FileObjectStorage) DeleteObject(ctx context.Context, objectKey string) error {
	started := time.Now()
	_, err := s.client.DeleteObject(ctx, &awss3.DeleteObjectInput{
		Bucket: awsv2.String(s.cfg.Bucket),
		Key:    awsv2.String(buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)),
	})
	metrics.ObserveAWSRequest(s.component, "s3", "DeleteObject", err, started)
	metrics.ObserveFileDelete(s.component, err)
	return err
}

func (s *FileObjectStorage) PutObjectTagging(ctx context.Context, objectKey string, tags map[string]string) error {
	if len(tags) == 0 {
		return nil
	}
	key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
	tagSet := make([]types.Tag, 0, len(tags))
	for k, v := range tags {
		keyTag := strings.TrimSpace(k)
		valueTag := strings.TrimSpace(v)
		if keyTag == "" || valueTag == "" {
			continue
		}
		tagSet = append(tagSet, types.Tag{
			Key:   awsv2.String(keyTag),
			Value: awsv2.String(valueTag),
		})
	}
	if len(tagSet) == 0 {
		return nil
	}

	started := time.Now()
	_, err := s.client.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
		Bucket:  awsv2.String(s.cfg.Bucket),
		Key:     awsv2.String(key),
		Tagging: &types.Tagging{TagSet: tagSet},
	})
	metrics.ObserveAWSRequest(s.component, "s3", "PutObjectTagging", err, started)
	return err
}

func (s *FileObjectStorage) DeleteObjectTaggingKeys(ctx context.Context, objectKey string, keys []string) error {
	if len(keys) == 0 {
		return nil
	}
	key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)

	started := time.Now()
	existing, err := s.client.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
		Bucket: awsv2.String(s.cfg.Bucket),
		Key:    awsv2.String(key),
	})
	metrics.ObserveAWSRequest(s.component, "s3", "GetObjectTagging", err, started)
	if err != nil {
		return err
	}
	removed := make(map[string]struct{}, len(keys))
	for i := range keys {
		k := strings.TrimSpace(keys[i])
		if k == "" {
			continue
		}
		removed[k] = struct{}{}
	}
	tagSet := make([]types.Tag, 0, len(existing.TagSet))
	for i := range existing.TagSet {
		tagKey := strings.TrimSpace(awsv2.ToString(existing.TagSet[i].Key))
		if _, drop := removed[tagKey]; drop {
			continue
		}
		tagSet = append(tagSet, existing.TagSet[i])
	}

	started = time.Now()
	_, err = s.client.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
		Bucket:  awsv2.String(s.cfg.Bucket),
		Key:     awsv2.String(key),
		Tagging: &types.Tagging{TagSet: tagSet},
	})
	metrics.ObserveAWSRequest(s.component, "s3", "PutObjectTagging", err, started)
	return err
}

func (s *FileObjectStorage) HeadObject(ctx context.Context, objectKey string) (filemanager.ObjectMetadata, error) {
	key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
	started := time.Now()
	output, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{
		Bucket: awsv2.String(s.cfg.Bucket),
		Key:    awsv2.String(key),
	})
	metrics.ObserveAWSRequest(s.component, "s3", "HeadObject", err, started)
	metrics.ObserveFileHead(s.component, err)
	if err != nil {
		if isObjectNotFoundError(err) {
			return filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
		}
		return filemanager.ObjectMetadata{}, err
	}

	return filemanager.ObjectMetadata{
		Bucket:       s.cfg.Bucket,
		ObjectKey:    key,
		ETag:         awsv2.ToString(output.ETag),
		VersionID:    awsv2.ToString(output.VersionId),
		SizeBytes:    awsv2.ToInt64(output.ContentLength),
		LastModified: output.LastModified,
		ContentType:  awsv2.ToString(output.ContentType),
	}, nil
}

func (s *FileObjectStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
	key := buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)
	started := time.Now()
	output, err := s.client.GetObject(ctx, &awss3.GetObjectInput{
		Bucket: awsv2.String(s.cfg.Bucket),
		Key:    awsv2.String(key),
	})
	metrics.ObserveAWSRequest(s.component, "s3", "GetObject", err, started)
	if err != nil {
		if isObjectNotFoundError(err) {
			return nil, filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
		}
		return nil, filemanager.ObjectMetadata{}, err
	}

	metrics.ObserveAWSTransferredBytes(s.component, "s3", "GetObject", "download", awsv2.ToInt64(output.ContentLength))
	return output.Body, filemanager.ObjectMetadata{
		Bucket:       s.cfg.Bucket,
		ObjectKey:    key,
		ETag:         awsv2.ToString(output.ETag),
		VersionID:    awsv2.ToString(output.VersionId),
		SizeBytes:    awsv2.ToInt64(output.ContentLength),
		LastModified: output.LastModified,
		ContentType:  awsv2.ToString(output.ContentType),
	}, nil
}

func (s *FileObjectStorage) PresignPutObject(ctx context.Context, input filemanager.PresignPutObjectInput) (filemanager.PresignedPutObject, error) {
	key := buildObjectKey(s.cfg.ObjectKeyPrefix, input.ObjectKey)
	ttl := input.TTL
	if ttl <= 0 {
		ttl = s.cfg.PresignPutTTL
	}
	if ttl <= 0 {
		ttl = 15 * time.Minute
	}

	putInput := &awss3.PutObjectInput{
		Bucket:      awsv2.String(s.cfg.Bucket),
		Key:         awsv2.String(key),
		ContentType: optionalString(input.ContentType),
		Metadata:    input.Metadata,
	}

	output, err := s.presigner.PresignPutObject(ctx, putInput, func(options *awss3.PresignOptions) {
		options.Expires = ttl
	})
	if err != nil {
		return filemanager.PresignedPutObject{}, err
	}

	return filemanager.PresignedPutObject{
		Bucket:    s.cfg.Bucket,
		ObjectKey: key,
		Method:    output.Method,
		URL:       output.URL,
		Headers:   canonicalSignedHeaders(output.SignedHeader),
		ExpiresAt: time.Now().UTC().Add(ttl),
	}, nil
}

func (s *FileObjectStorage) PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error) {
	if ttl <= 0 {
		ttl = s.cfg.PresignGetTTL
	}

	started := time.Now()
	output, err := s.presigner.PresignGetObject(ctx, &awss3.GetObjectInput{
		Bucket: awsv2.String(s.cfg.Bucket),
		Key:    awsv2.String(buildObjectKey(s.cfg.ObjectKeyPrefix, objectKey)),
	}, func(options *awss3.PresignOptions) {
		options.Expires = ttl
	})
	metrics.ObserveAWSRequest(s.component, "s3", "GetObject", err, started)
	metrics.ObserveFilePresignDownload(s.component, err)
	if err != nil {
		return "", err
	}
	return output.URL, nil
}

func validateConfig(cfg config.FileStorageConfig) error {
	if strings.ToLower(strings.TrimSpace(cfg.Provider)) != "s3" {
		return errors.New("unsupported file storage provider")
	}
	if strings.TrimSpace(cfg.Bucket) == "" {
		return errors.New("file storage bucket is required")
	}
	if strings.TrimSpace(cfg.Region) == "" {
		return errors.New("file storage region is required")
	}
	if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
		parsed, err := url.Parse(endpoint)
		if err != nil {
			return err
		}
		if parsed.Scheme != "https" && !cfg.AllowInsecureEndpoint {
			return errors.New("insecure file storage endpoint is disabled")
		}
	}
	return nil
}

func buildObjectKey(prefix, objectKey string) string {
	objectKey = strings.TrimSpace(objectKey)
	objectKey = strings.TrimPrefix(objectKey, "/")
	objectKey = strings.TrimPrefix(objectKey, "./")
	if objectKey == "" {
		return objectKey
	}

	prefix = strings.TrimSpace(prefix)
	prefix = strings.Trim(prefix, "/")
	if prefix == "" {
		return objectKey
	}
	if objectKey == prefix || strings.HasPrefix(objectKey, prefix+"/") {
		return objectKey
	}
	return prefix + "/" + objectKey
}

func optionalString(value string) *string {
	value = strings.TrimSpace(value)
	if value == "" {
		return nil
	}
	return awsv2.String(value)
}

func isBucketAlreadyOwnedByYou(err error) bool {
	var apiErr smithy.APIError
	if !errors.As(err, &apiErr) {
		return false
	}
	return apiErr.ErrorCode() == "BucketAlreadyOwnedByYou" || apiErr.ErrorCode() == "BucketAlreadyExists"
}

func isObjectNotFoundError(err error) bool {
	var apiErr smithy.APIError
	if !errors.As(err, &apiErr) {
		return false
	}
	switch strings.TrimSpace(apiErr.ErrorCode()) {
	case "NotFound", "NoSuchKey", "404":
		return true
	default:
		return false
	}
}

func canonicalSignedHeaders(header http.Header) map[string]string {
	if len(header) == 0 {
		return nil
	}
	out := make(map[string]string, len(header))
	for key, values := range header {
		k := strings.TrimSpace(strings.ToLower(key))
		if k == "" || len(values) == 0 {
			continue
		}
		out[k] = strings.TrimSpace(values[0])
	}
	return out
}
