// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

package media

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"strings"
	"time"

	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
	"github.com/superseriousbusiness/gotosocial/internal/db"
	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
	"github.com/superseriousbusiness/gotosocial/internal/media"
	"github.com/superseriousbusiness/gotosocial/internal/regexes"
	"github.com/superseriousbusiness/gotosocial/internal/storage"
	"github.com/superseriousbusiness/gotosocial/internal/uris"
)

// GetFile retrieves a file from storage and streams it back
// to the caller via an io.reader embedded in *apimodel.Content.
func (p *Processor) GetFile(
	ctx context.Context,
	requester *gtsmodel.Account,
	form *apimodel.GetContentRequestForm,
) (*apimodel.Content, gtserror.WithCode) {
	// Parse media size (small, static, original).
	mediaSize, err := parseSize(form.MediaSize)
	if err != nil {
		err := gtserror.Newf("media size %s not valid", form.MediaSize)
		return nil, gtserror.NewErrorNotFound(err)
	}

	// Parse media type (emoji, header, avatar, attachment).
	mediaType, err := parseType(form.MediaType)
	if err != nil {
		err := gtserror.Newf("media type %s not valid", form.MediaType)
		return nil, gtserror.NewErrorNotFound(err)
	}

	// Parse media ID from file name.
	mediaID, _, err := parseFileName(form.FileName)
	if err != nil {
		err := gtserror.Newf("media file name %s not valid", form.FileName)
		return nil, gtserror.NewErrorNotFound(err)
	}

	// Get the account that owns the media
	// and make sure it's not suspended.
	acctID := form.AccountID
	acct, err := p.state.DB.GetAccountByID(ctx, acctID)
	if err != nil {
		err := gtserror.Newf("db error getting account %s: %w", acctID, err)
		return nil, gtserror.NewErrorNotFound(err)
	}

	if acct.IsSuspended() {
		err := gtserror.Newf("account %s is suspended", acctID)
		return nil, gtserror.NewErrorNotFound(err)
	}

	// If requester was authenticated, ensure media
	// owner and requester don't block each other.
	if requester != nil {
		blocked, err := p.state.DB.IsEitherBlocked(ctx, requester.ID, acctID)
		if err != nil {
			err := gtserror.Newf("db error checking block between %s and %s: %w", acctID, requester.ID, err)
			return nil, gtserror.NewErrorNotFound(err)
		}

		if blocked {
			err := gtserror.Newf("block exists between %s and %s", acctID, requester.ID)
			return nil, gtserror.NewErrorNotFound(err)
		}
	}

	// The way we store emojis is a bit different
	// from the way we store other attachments,
	// so we need to take different steps depending
	// on the media type being requested.
	switch mediaType {

	case media.TypeEmoji:
		return p.getEmojiContent(ctx,
			acctID,
			mediaSize,
			mediaID,
		)

	case media.TypeAttachment, media.TypeHeader, media.TypeAvatar:
		return p.getAttachmentContent(ctx,
			requester,
			acctID,
			mediaSize,
			mediaID,
		)

	default:
		err := gtserror.Newf("media type %s not recognized", mediaType)
		return nil, gtserror.NewErrorNotFound(err)
	}
}

func (p *Processor) getAttachmentContent(
	ctx context.Context,
	requester *gtsmodel.Account,
	acctID string,
	sizeStr media.Size,
	mediaID string,
) (
	*apimodel.Content,
	gtserror.WithCode,
) {
	// Get attachment with given ID from the database.
	attach, err := p.state.DB.GetAttachmentByID(ctx, mediaID)
	if err != nil && !errors.Is(err, db.ErrNoEntries) {
		err := gtserror.Newf("db error getting attachment %s: %w", mediaID, err)
		return nil, gtserror.NewErrorInternalError(err)
	}

	if attach == nil {
		const text = "media not found"
		return nil, gtserror.NewErrorNotFound(errors.New(text), text)
	}

	// Ensure the account
	// actually owns the media.
	if attach.AccountID != acctID {
		const text = "media was not owned by passed account id"
		return nil, gtserror.NewErrorNotFound(errors.New(text) /* no help text! */)
	}

	// Unknown file types indicate no *locally*
	// stored data we can serve. Handle separately.
	if attach.Type == gtsmodel.FileTypeUnknown {
		return handleUnknown(attach)
	}

	// If requester was provided, use their username
	// to create a transport to potentially re-fetch
	// the media. Else falls back to instance account.
	var requestUser string
	if requester != nil {
		requestUser = requester.Username
	}

	// Ensure that stored media is cached.
	// (this handles local media / recaches).
	attach, err = p.federator.RefreshMedia(
		ctx,
		requestUser,
		attach,
		media.AdditionalMediaInfo{},
		false,
	)
	if err != nil {
		err := gtserror.Newf("error recaching media: %w", err)
		return nil, gtserror.NewErrorNotFound(err)
	}

	// Start preparing API content model.
	apiContent := &apimodel.Content{
		ContentUpdated: attach.UpdatedAt,
	}

	// Retrieve appropriate
	// size file from storage.
	switch sizeStr {

	case media.SizeOriginal:
		apiContent.ContentType = attach.File.ContentType
		apiContent.ContentLength = int64(attach.File.FileSize)
		return p.getContent(ctx,
			attach.File.Path,
			apiContent,
		)

	case media.SizeSmall:
		apiContent.ContentType = attach.Thumbnail.ContentType
		apiContent.ContentLength = int64(attach.Thumbnail.FileSize)
		return p.getContent(ctx,
			attach.Thumbnail.Path,
			apiContent,
		)

	default:
		const text = "invalid media attachment size"
		return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
	}
}

func (p *Processor) getEmojiContent(
	ctx context.Context,
	acctID string,
	sizeStr media.Size,
	emojiID string,
) (
	*apimodel.Content,
	gtserror.WithCode,
) {
	// Reconstruct static emoji image URL to search for it.
	// As refreshed emojis use a newly generated path ID to
	// differentiate them (cache-wise) from the original.
	staticURL := uris.URIForAttachment(
		acctID,
		string(media.TypeEmoji),
		string(media.SizeStatic),
		emojiID,
		"png",
	)

	// Search for emoji with given static URL in the database.
	emoji, err := p.state.DB.GetEmojiByStaticURL(ctx, staticURL)
	if err != nil && !errors.Is(err, db.ErrNoEntries) {
		err := gtserror.Newf("error fetching emoji from database: %w", err)
		return nil, gtserror.NewErrorInternalError(err)
	}

	if emoji == nil {
		const text = "emoji not found"
		return nil, gtserror.NewErrorNotFound(errors.New(text), text)
	}

	if *emoji.Disabled {
		const text = "emoji has been disabled"
		return nil, gtserror.NewErrorNotFound(errors.New(text), text)
	}

	// Ensure that stored emoji is cached.
	// (this handles local emoji / recaches).
	emoji, err = p.federator.RecacheEmoji(
		ctx,
		emoji,
	)
	if err != nil {
		err := gtserror.Newf("error recaching emoji: %w", err)
		return nil, gtserror.NewErrorNotFound(err)
	}

	// Start preparing API content model.
	apiContent := &apimodel.Content{}

	// Retrieve appropriate
	// size file from storage.
	switch sizeStr {

	case media.SizeOriginal:
		apiContent.ContentType = emoji.ImageContentType
		apiContent.ContentLength = int64(emoji.ImageFileSize)
		return p.getContent(ctx,
			emoji.ImagePath,
			apiContent,
		)

	case media.SizeStatic:
		apiContent.ContentType = emoji.ImageStaticContentType
		apiContent.ContentLength = int64(emoji.ImageStaticFileSize)
		return p.getContent(ctx,
			emoji.ImageStaticPath,
			apiContent,
		)

	default:
		const text = "invalid media attachment size"
		return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
	}
}

// getContent performs the final file fetching of
// stored content at path in storage. This is
// populated in the apimodel.Content{} and returned.
// (note: this also handles un-proxied S3 storage).
func (p *Processor) getContent(
	ctx context.Context,
	path string,
	content *apimodel.Content,
) (
	*apimodel.Content,
	gtserror.WithCode,
) {
	// If running on S3 storage with proxying disabled then
	// just fetch pre-signed URL instead of the content.
	if url := p.state.Storage.URL(ctx, path); url != nil {
		content.URL = url
		return content, nil
	}

	// Fetch file stream for the stored media at path.
	rc, err := p.state.Storage.GetStream(ctx, path)
	if err != nil && !storage.IsNotFound(err) {
		err := gtserror.Newf("error getting file %s from storage: %w", path, err)
		return nil, gtserror.NewErrorInternalError(err)
	}

	// Ensure found.
	if rc == nil {
		err := gtserror.Newf("file not found at %s", path)
		const text = "file not found"
		return nil, gtserror.NewErrorNotFound(err, text)
	}

	// Return with stream.
	content.Content = rc
	return content, nil
}

// handles serving Content for "unknown" file
// type, ie., a file we couldn't cache (this time).
func handleUnknown(
	attach *gtsmodel.MediaAttachment,
) (*apimodel.Content, gtserror.WithCode) {
	if attach.RemoteURL == "" {
		err := gtserror.Newf("empty remote url for %s", attach.ID)
		return nil, gtserror.NewErrorInternalError(err)
	}

	// Parse media remote URL to valid URL object.
	remoteURL, err := url.Parse(attach.RemoteURL)
	if err != nil {
		err := gtserror.Newf("invalid remote url for %s: %w", attach.ID, err)
		return nil, gtserror.NewErrorInternalError(err)
	}

	if remoteURL == nil {
		err := gtserror.Newf("nil remote url for %s", attach.ID)
		return nil, gtserror.NewErrorInternalError(err)
	}

	// Just forward the request to the remote URL,
	// since this is a type we couldn't process.
	url := &storage.PresignedURL{
		URL: remoteURL,

		// We might manage to cache the media
		// at some point, so set a low-ish expiry.
		Expiry: time.Now().Add(2 * time.Hour),
	}

	return &apimodel.Content{URL: url}, nil
}

func parseType(s string) (media.Type, error) {
	switch s {
	case string(media.TypeAttachment):
		return media.TypeAttachment, nil
	case string(media.TypeHeader):
		return media.TypeHeader, nil
	case string(media.TypeAvatar):
		return media.TypeAvatar, nil
	case string(media.TypeEmoji):
		return media.TypeEmoji, nil
	}
	return "", fmt.Errorf("%s not a recognized media.Type", s)
}

func parseSize(s string) (media.Size, error) {
	switch s {
	case string(media.SizeSmall):
		return media.SizeSmall, nil
	case string(media.SizeOriginal):
		return media.SizeOriginal, nil
	case string(media.SizeStatic):
		return media.SizeStatic, nil
	}
	return "", fmt.Errorf("%s not a recognized media.Size", s)
}

// Extract the mediaID and file extension from
// a string like "01J3CTH8CZ6ATDNMG6CPRC36XE.gif"
func parseFileName(s string) (string, string, error) {
	spl := strings.Split(s, ".")
	if len(spl) != 2 || spl[0] == "" || spl[1] == "" {
		return "", "", errors.New("file name not splittable on '.'")
	}

	var (
		mediaID  = spl[0]
		mediaExt = spl[1]
	)

	if !regexes.ULID.MatchString(mediaID) {
		return "", "", fmt.Errorf("%s not a valid ULID", mediaID)
	}

	return mediaID, mediaExt, nil
}