package discord

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

	"git.handmade.network/hmn/hmn/src/assets"
	"git.handmade.network/hmn/hmn/src/config"
	"git.handmade.network/hmn/hmn/src/db"
	"git.handmade.network/hmn/hmn/src/hmndata"
	"git.handmade.network/hmn/hmn/src/logging"
	"git.handmade.network/hmn/hmn/src/models"
	"git.handmade.network/hmn/hmn/src/oops"
	"git.handmade.network/hmn/hmn/src/parsing"
	"github.com/google/uuid"
)

func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, createSnippets bool) error {
	deleted := false
	var err error

	// NOTE(asaf): All functions called here should verify that the message applies to them.

	if !deleted && err == nil {
		deleted, err = CleanUpLibrary(ctx, dbConn, msg)
	}

	if !deleted && err == nil {
		deleted, err = CleanUpShowcase(ctx, dbConn, msg)
	}

	if !deleted && err == nil {
		err = MaybeInternMessage(ctx, dbConn, msg)
	}

	if err == nil {
		err = HandleInternedMessage(ctx, dbConn, msg, deleted, createSnippets)
	}

	return err
}

func CleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) {
	deleted := false
	if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
		switch msg.Type {
		case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
		default:
			return deleted, nil
		}

		hasGoodContent := true
		if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
			hasGoodContent = false
		}

		hasGoodAttachments := true
		if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
			hasGoodAttachments = false
		}

		if !hasGoodContent && !hasGoodAttachments {
			err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
			if err != nil {
				return deleted, oops.New(err, "failed to delete message")
			}
			deleted = true

			if !msg.Author.IsBot {
				channel, err := CreateDM(ctx, msg.Author.ID)
				if err != nil {
					return deleted, oops.New(err, "failed to create DM channel")
				}

				err = SendMessages(ctx, dbConn, MessageToSend{
					ChannelID: channel.ID,
					Req: CreateMessageRequest{
						Content: "Posts in #project-showcase are required to have either an image/video or a link. Discuss showcase content in #projects.",
					},
				})
				if err != nil {
					return deleted, oops.New(err, "failed to send showcase warning message")
				}
			}
		}
	}

	return deleted, nil
}

func CleanUpLibrary(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) {
	deleted := false
	if msg.ChannelID == config.Config.Discord.LibraryChannelID {
		switch msg.Type {
		case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
		default:
			return deleted, nil
		}

		if !msg.OriginalHasFields("content") {
			return deleted, nil
		}

		if !messageHasLinks(msg.Content) {
			err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
			if err != nil {
				return deleted, oops.New(err, "failed to delete message")
			}
			deleted = true

			if !msg.Author.IsBot {
				channel, err := CreateDM(ctx, msg.Author.ID)
				if err != nil {
					return deleted, oops.New(err, "failed to create DM channel")
				}

				err = SendMessages(ctx, dbConn, MessageToSend{
					ChannelID: channel.ID,
					Req: CreateMessageRequest{
						Content: "Posts in #the-library are required to have a link. Discuss library content in other relevant channels.",
					},
				})
				if err != nil {
					return deleted, oops.New(err, "failed to send showcase warning message")
				}
			}
		}
	}

	return deleted, nil
}

func MaybeInternMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
	if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
		err := InternMessage(ctx, dbConn, msg)
		if errors.Is(err, errNotEnoughInfo) {
			logging.ExtractLogger(ctx).Warn().
				Interface("msg", msg).
				Msg("didn't have enough info to intern Discord message")
		} else if err != nil {
			return err
		}
	}
	return nil
}

var errNotEnoughInfo = errors.New("Discord didn't send enough info in this event for us to do this")

/*
Ensures that a Discord message is stored in the database. This function is
idempotent and can be called regardless of whether the item already exists in
the database.

This does not create snippets or save content or do anything besides save the message itself.
*/
func InternMessage(
	ctx context.Context,
	dbConn db.ConnOrTx,
	msg *Message,
) error {
	_, err := db.QueryOne[models.DiscordMessage](ctx, dbConn,
		`
		SELECT $columns
		FROM discord_message
		WHERE id = $1
		`,
		msg.ID,
	)
	if errors.Is(err, db.NotFound) {
		if !msg.OriginalHasFields("author", "timestamp") {
			return errNotEnoughInfo
		}

		guildID := msg.GuildID
		if guildID == nil {
			/*
				This is weird, but it can happen when we fetch messages from
				history instead of receiving it from the gateway. In this case
				we just assume it's from the HMN server.
			*/
			guildID = &config.Config.Discord.GuildID
		}

		_, err = dbConn.Exec(ctx,
			`
			INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
			VALUES ($1, $2, $3, $4, $5, $6, $7)
			`,
			msg.ID,
			msg.ChannelID,
			*guildID,
			msg.JumpURL(),
			msg.Author.ID,
			msg.Time(),
			false,
		)
		if err != nil {
			return oops.New(err, "failed to save new discord message")
		}
	} else if err != nil {
		return oops.New(err, "failed to check for existing Discord message")
	}

	return nil
}

type InternedMessage struct {
	Message        models.DiscordMessage         `db:"message"`
	MessageContent *models.DiscordMessageContent `db:"content"`
	HMNUser        *models.User                  `db:"hmnuser"`
	DiscordUser    *models.DiscordUser           `db:"duser"`
}

func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string) (*InternedMessage, error) {
	interned, err := db.QueryOne[InternedMessage](ctx, dbConn,
		`
		SELECT $columns
		FROM
			discord_message AS message
			LEFT JOIN discord_message_content AS content ON content.message_id = message.id
			LEFT JOIN discord_user AS duser ON duser.userid = message.user_id
			LEFT JOIN hmn_user AS hmnuser ON hmnuser.id = duser.hmn_user_id
		WHERE message.id = $1
		`,
		msgId,
	)
	if err != nil {
		return nil, err
	}
	return interned, nil
}

// Checks if a message is interned and handles it to the extent possible:
// 1. Saves/updates content
// 2. Saves/updates snippet
// 3. Deletes content/snippet
func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, deleted bool, createSnippet bool) error {
	tx, err := dbConn.Begin(ctx)
	if err != nil {
		return oops.New(err, "failed to start transaction")
	}
	defer tx.Rollback(ctx)

	interned, err := FetchInternedMessage(ctx, tx, msg.ID)
	if err != nil && !errors.Is(err, db.NotFound) {
		return err
	} else if err == nil {
		if !deleted {
			err = SaveMessageContents(ctx, tx, interned, msg)
			if err != nil {
				return err

			}
			if createSnippet {
				err = HandleSnippetForInternedMessage(ctx, tx, interned, false)
				if err != nil {
					return err
				}
			}
		} else {
			err = DeleteInternedMessage(ctx, tx, interned)
			if err != nil {
				return err
			}
		}
	}
	err = tx.Commit(ctx)
	if err != nil {
		return oops.New(err, "failed to commit Discord message updates")
	}

	return nil
}

func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage) error {
	snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
		`
		SELECT $columns
		FROM snippet
		WHERE discord_message_id = $1
		`,
		interned.Message.ID,
	)
	if err != nil && !errors.Is(err, db.NotFound) {
		return oops.New(err, "failed to fetch snippet for discord message")
	}

	// NOTE(asaf): Also deletes the following through a db cascade:
	//			   * discord_message_attachment
	//			   * discord_message_content
	//			   * discord_message_embed
	//             DOES NOT DELETE ASSETS FOR CONTENT/EMBEDS
	_, err = dbConn.Exec(ctx,
		`
		DELETE FROM discord_message
		WHERE id = $1
		`,
		interned.Message.ID,
	)

	if snippet != nil {
		userApprovesDeletion := interned.HMNUser != nil && snippet.OwnerID == interned.HMNUser.ID && interned.HMNUser.DiscordDeleteSnippetOnMessageDelete
		if !snippet.EditedOnWebsite && userApprovesDeletion {
			// NOTE(asaf): Does not delete asset!
			_, err = dbConn.Exec(ctx,
				`
				DELETE FROM snippet
				WHERE id = $1
				`,
				snippet.ID,
			)
			if err != nil {
				return oops.New(err, "failed to delete snippet")
			}
		}
	}

	return nil
}

/*
Processes a single Discord message, saving as much of the message's content
and attachments as allowed by our rules and user settings. Does NOT create
snippets.

Idempotent; can be called any time whether the contents exist or not.

NOTE!!: Replaces interned.MessageContent if it was created or updated!!
*/
func SaveMessageContents(
	ctx context.Context,
	dbConn db.ConnOrTx,
	interned *InternedMessage,
	msg *Message,
) error {
	if interned.DiscordUser != nil {
		// We have a linked Discord account, so save the message contents (regardless of
		// whether we create a snippet or not).
		if msg.OriginalHasFields("content") {
			_, err := dbConn.Exec(ctx,
				`
				INSERT INTO discord_message_content (message_id, discord_id, last_content)
				VALUES ($1, $2, $3)
				ON CONFLICT (message_id) DO UPDATE SET
					discord_id = EXCLUDED.discord_id,
					last_content = EXCLUDED.last_content
				`,
				interned.Message.ID,
				interned.DiscordUser.ID,
				CleanUpMarkdown(ctx, msg.Content),
			)
			if err != nil {
				return oops.New(err, "failed to create or update message contents")
			}

			content, err := db.QueryOne[models.DiscordMessageContent](ctx, dbConn,
				`
				SELECT $columns
				FROM
					discord_message_content
				WHERE
					discord_message_content.message_id = $1
				`,
				interned.Message.ID,
			)
			if err != nil {
				return oops.New(err, "failed to fetch message contents")
			}
			interned.MessageContent = content
		}

		// Save attachments
		if msg.OriginalHasFields("attachments") {
			for _, attachment := range msg.Attachments {
				_, err := saveAttachment(ctx, dbConn, &attachment, interned.DiscordUser.HMNUserId, msg.ID)
				if err != nil {
					return oops.New(err, "failed to save attachment")
				}
			}
		}

		// Save / delete embeds
		if msg.OriginalHasFields("embeds") {
			numSavedEmbeds, err := db.QueryOneScalar[int](ctx, dbConn,
				`
				SELECT COUNT(*)
				FROM discord_message_embed
				WHERE message_id = $1
				`,
				msg.ID,
			)
			if err != nil {
				return oops.New(err, "failed to count existing embeds")
			}
			if numSavedEmbeds == 0 {
				// No embeds yet, so save new ones
				for _, embed := range msg.Embeds {
					_, err := saveEmbed(ctx, dbConn, &embed, interned.DiscordUser.HMNUserId, msg.ID)
					if err != nil {
						return oops.New(err, "failed to save embed")
					}
				}
			} else if len(msg.Embeds) > 0 {
				// Embeds were removed from the message
				_, err := dbConn.Exec(ctx,
					`
				DELETE FROM discord_message_embed
				WHERE message_id = $1
				`,
					msg.ID,
				)
				if err != nil {
					return oops.New(err, "failed to delete embeds")
				}
			}
		}
	}
	return nil
}

var discordDownloadClient = &http.Client{
	Timeout: 10 * time.Second,
}

type DiscordResourceBadStatusCode error

func downloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, "", oops.New(err, "failed to make Discord download request")
	}
	res, err := discordDownloadClient.Do(req)
	if err != nil {
		return nil, "", oops.New(err, "failed to fetch Discord resource data")
	}
	defer res.Body.Close()

	if res.StatusCode < 200 || 299 < res.StatusCode {
		return nil, "", DiscordResourceBadStatusCode(fmt.Errorf("status code %d from Discord resource: %s", res.StatusCode, url))
	}

	content, err := io.ReadAll(res.Body)
	if err != nil {
		logging.ExtractLogger(ctx).Error().Str("Url", url).Msg("failed to download resource")
		return nil, "", err
	}

	return content, res.Header.Get("Content-Type"), nil
}

/*
Saves a Discord attachment as an HMN asset. Idempotent; will not create an attachment
that already exists
*/
func saveAttachment(
	ctx context.Context,
	tx db.ConnOrTx,
	attachment *Attachment,
	hmnUserID int,
	discordMessageID string,
) (*models.DiscordMessageAttachment, error) {
	existing, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
		`
		SELECT $columns
		FROM discord_message_attachment
		WHERE id = $1
		`,
		attachment.ID,
	)
	if err == nil {
		return existing, nil
	} else if errors.Is(err, db.NotFound) {
		// this is fine, just create it
	} else {
		return nil, oops.New(err, "failed to check for existing attachment")
	}

	width := 0
	height := 0
	if attachment.Width != nil {
		width = *attachment.Width
	}
	if attachment.Height != nil {
		height = *attachment.Height
	}

	content, _, err := downloadDiscordResource(ctx, attachment.Url)
	if err != nil {
		return nil, oops.New(err, "failed to download Discord attachment")
	}

	contentType := "application/octet-stream"
	if attachment.ContentType != nil {
		contentType = *attachment.ContentType
	}

	asset, err := assets.Create(ctx, tx, assets.CreateInput{
		Content:     content,
		Filename:    attachment.Filename,
		ContentType: contentType,

		UploaderID: &hmnUserID,
		Width:      width,
		Height:     height,
	})
	if err != nil {
		return nil, oops.New(err, "failed to save asset for Discord attachment")
	}

	// TODO(db): RETURNING plz thanks
	_, err = tx.Exec(ctx,
		`
		INSERT INTO discord_message_attachment (id, asset_id, message_id)
		VALUES ($1, $2, $3)
		`,
		attachment.ID,
		asset.ID,
		discordMessageID,
	)
	if err != nil {
		return nil, oops.New(err, "failed to save Discord attachment data")
	}

	discordAttachment, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
		`
		SELECT $columns
		FROM discord_message_attachment
		WHERE id = $1
		`,
		attachment.ID,
	)
	if err != nil {
		return nil, oops.New(err, "failed to fetch new Discord attachment data")
	}

	return discordAttachment, nil
}

// Saves an embed from Discord. NOTE: This is _not_ idempotent, so only call it
// if you do not have any embeds saved for this message yet.
func saveEmbed(
	ctx context.Context,
	tx db.ConnOrTx,
	embed *Embed,
	hmnUserID int,
	discordMessageID string,
) (*models.DiscordMessageEmbed, error) {
	isOkImageType := func(contentType string) bool {
		return strings.HasPrefix(contentType, "image/")
	}

	isOkVideoType := func(contentType string) bool {
		return strings.HasPrefix(contentType, "video/")
	}

	maybeSaveImageish := func(i EmbedImageish, contentTypeCheck func(string) bool) (*uuid.UUID, error) {
		content, contentType, err := downloadDiscordResource(ctx, *i.Url)
		if err != nil {
			var statusError DiscordResourceBadStatusCode
			if errors.As(err, &statusError) {
				return nil, nil
			} else {
				return nil, oops.New(err, "failed to save Discord embed")
			}
		}
		if contentTypeCheck(contentType) {
			in := assets.CreateInput{
				Content:     content,
				Filename:    "embed",
				ContentType: contentType,
				UploaderID:  &hmnUserID,
			}

			if i.Width != nil {
				in.Width = *i.Width
			}
			if i.Height != nil {
				in.Height = *i.Height
			}

			asset, err := assets.Create(ctx, tx, in)
			if err != nil {
				return nil, oops.New(err, "failed to create asset from embed")
			}
			return &asset.ID, nil
		}

		return nil, nil
	}

	var imageAssetId *uuid.UUID
	var videoAssetId *uuid.UUID
	var err error

	if embed.Video != nil && embed.Video.Url != nil {
		videoAssetId, err = maybeSaveImageish(embed.Video.EmbedImageish, isOkVideoType)
	} else if embed.Image != nil && embed.Image.Url != nil {
		imageAssetId, err = maybeSaveImageish(embed.Image.EmbedImageish, isOkImageType)
	} else if embed.Thumbnail != nil && embed.Thumbnail.Url != nil {
		imageAssetId, err = maybeSaveImageish(embed.Thumbnail.EmbedImageish, isOkImageType)
	}
	if err != nil {
		return nil, err
	}

	// Save the embed into the db
	// TODO(db): Insert, RETURNING
	var savedEmbedId int
	err = tx.QueryRow(ctx,
		`
		INSERT INTO discord_message_embed (title, description, url, message_id, image_id, video_id)
		VALUES ($1, $2, $3, $4, $5, $6)
		RETURNING id
		`,
		embed.Title,
		embed.Description,
		embed.Url,
		discordMessageID,
		imageAssetId,
		videoAssetId,
	).Scan(&savedEmbedId)
	if err != nil {
		return nil, oops.New(err, "failed to insert new embed")
	}

	discordEmbed, err := db.QueryOne[models.DiscordMessageEmbed](ctx, tx,
		`
		SELECT $columns
		FROM discord_message_embed
		WHERE id = $1
		`,
		savedEmbedId,
	)
	if err != nil {
		return nil, oops.New(err, "failed to fetch new Discord embed data")
	}

	return discordEmbed, nil
}

func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID string) (*models.Snippet, error) {
	snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
		`
		SELECT $columns
		FROM snippet
		WHERE discord_message_id = $1
		`,
		msgID,
	)

	if err != nil {
		if errors.Is(err, db.NotFound) {
			return nil, nil
		} else {
			return nil, oops.New(err, "failed to fetch existing snippet for message %s", msgID)
		}
	}

	return snippet, nil
}

/*
Potentially creates or updates a snippet for the given interned message.
It uses the content saved in the database to do this. If we do not have any
content saved, nothing will happen.

If a user does not have their Discord account linked, this function will
naturally do nothing because we have no message content saved.
If forceCreate is true, it does not check any user settings such as automatically creating snippets from
#project-showcase. If we have the content, it will make a snippet for it, no
questions asked. Bear that in mind.
*/
func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage, forceCreate bool) error {
	if interned.HMNUser == nil {
		// NOTE(asaf): Can't handle snippets when there's no linked user
		return nil
	}

	if interned.MessageContent == nil {
		// NOTE(asaf): Can't have a snippet without content
		// NOTE(asaf): Messages that only have an attachment also have blank content
		// TODO(asaf): Do we need to delete existing snippets in this case??? Not entirely sure how to trigger this through discord
		return nil
	}

	tx, err := dbConn.Begin(ctx)
	if err != nil {
		oops.New(err, "failed to start transaction")
	}
	defer tx.Rollback(ctx)

	existingSnippet, err := FetchSnippetForMessage(ctx, tx, interned.Message.ID)
	if err != nil {
		return oops.New(err, "failed to check for existing snippet for message %s", interned.Message.ID)
	}

	if existingSnippet != nil {
		// TODO(asaf): We're not handling the case where embeds were removed or modified.
		//             Also not handling the case where a message had both an attachment and an embed
		//             and the attachment was removed (leaving only the embed).
		linkedUserIsSnippetOwner := existingSnippet.OwnerID == interned.DiscordUser.HMNUserId
		if linkedUserIsSnippetOwner && !existingSnippet.EditedOnWebsite {
			contentMarkdown := interned.MessageContent.LastContent
			contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)

			_, err := tx.Exec(ctx,
				`
				UPDATE snippet
				SET
					description = $1,
					_description_html = $2
				WHERE id = $3
				`,
				contentMarkdown,
				contentHTML,
				existingSnippet.ID,
			)
			if err != nil {
				return oops.New(err, "failed to update content of snippet on message edit")
			}
			existingSnippet.Description = contentMarkdown
			existingSnippet.DescriptionHtml = contentHTML
		}
	} else {
		userAllowsSnippet := interned.HMNUser.DiscordSaveShowcase || forceCreate
		shouldCreate := !interned.Message.SnippetCreated && userAllowsSnippet

		if shouldCreate {
			// Get an asset ID or URL to make a snippet from
			assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &interned.Message)
			if assetId != nil || url != nil {
				contentMarkdown := interned.MessageContent.LastContent
				contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)

				_, err = tx.Exec(ctx,
					`
					INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
					VALUES ($1, $2, $3, $4, $5, $6, $7)
					`,
					url,
					interned.Message.SentAt,
					contentMarkdown,
					contentHTML,
					assetId,
					interned.Message.ID,
					interned.HMNUser.ID,
				)
				if err != nil {
					return oops.New(err, "failed to create snippet from attachment")
				}

				existingSnippet, err = FetchSnippetForMessage(ctx, tx, interned.Message.ID)
				if err != nil {
					return oops.New(err, "failed to fetch newly-created snippet")
				}

				_, err = tx.Exec(ctx,
					`
					UPDATE discord_message
					SET snippet_created = TRUE
					WHERE id = $1
					`,
					interned.Message.ID,
				)
				if err != nil {
					return oops.New(err, "failed to mark message as having snippet")
				}
			}
		}
	}

	if existingSnippet != nil {
		// Update tags

		// Try to associate tags in the message with project tags in HMN.
		// Match only tags for projects in which the current user is a collaborator.
		messageTags := getDiscordTags(existingSnippet.Description)

		var desiredTags []int
		var allTags []int

		// Fetch projects so we know what tags the user can apply to their snippet.
		projects, err := hmndata.FetchProjects(ctx, tx, interned.HMNUser, hmndata.ProjectsQuery{
			OwnerIDs: []int{interned.HMNUser.ID},
		})
		if err != nil {
			return oops.New(err, "failed to look up user projects")
		}

		projectIDs := make([]int, len(projects))
		for i, p := range projects {
			projectIDs[i] = p.Project.ID
		}

		userTags, err := db.Query[models.Tag](ctx, tx,
			`
			SELECT $columns{tag}
			FROM
				tag
				JOIN project ON project.tag = tag.id
			WHERE
				project.id = ANY ($1)
			`,
			projectIDs,
		)
		if err != nil {
			return oops.New(err, "failed to fetch tags for user projects")
		}

		for _, tag := range userTags {
			allTags = append(allTags, tag.ID)
			for _, messageTag := range messageTags {
				if strings.EqualFold(tag.Text, messageTag) {
					desiredTags = append(desiredTags, tag.ID)
				}
			}
		}

		_, err = tx.Exec(ctx,
			`
			DELETE FROM snippet_tag
			WHERE
				snippet_id = $1
				AND tag_id = ANY ($2)
			`,
			existingSnippet.ID,
			allTags,
		)
		if err != nil {
			return oops.New(err, "failed to clear tags from snippet")
		}

		for _, tagID := range desiredTags {
			_, err = tx.Exec(ctx,
				`
				INSERT INTO snippet_tag (snippet_id, tag_id)
				VALUES ($1, $2)
				ON CONFLICT DO NOTHING
				`,
				existingSnippet.ID,
				tagID,
			)
			if err != nil {
				return oops.New(err, "failed to associate snippet with tag")
			}
		}
	}

	err = tx.Commit(ctx)
	if err != nil {
		return oops.New(err, "failed to commit transaction")
	}

	return nil
}

// TODO(asaf): I believe this will also match https://example.com?hello=1&whatever=5
//             Probably need to add word boundaries.
var REDiscordTag = regexp.MustCompile(`&([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)`)

func getDiscordTags(content string) []string {
	matches := REDiscordTag.FindAllStringSubmatch(content, -1)
	result := make([]string, len(matches))
	for i, m := range matches {
		result[i] = m[1]
	}
	return result
}

// NOTE(ben): This is maybe redundant with the regexes we use for markdown. But
// do we actually want to reuse those, or should we keep them separate?
var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)

func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
	// Check attachments
	attachments, err := db.Query[models.DiscordMessageAttachment](ctx, tx,
		`
		SELECT $columns
		FROM discord_message_attachment
		WHERE message_id = $1
		`,
		msg.ID,
	)
	if err != nil {
		return nil, nil, oops.New(err, "failed to fetch message attachments")
	}
	for _, attachment := range attachments {
		return &attachment.AssetID, nil, nil
	}

	// Check embeds
	embeds, err := db.Query[models.DiscordMessageEmbed](ctx, tx,
		`
		SELECT $columns
		FROM discord_message_embed
		WHERE message_id = $1
		`,
		msg.ID,
	)
	if err != nil {
		return nil, nil, oops.New(err, "failed to fetch discord embeds")
	}
	for _, embed := range embeds {
		if embed.VideoID != nil {
			return embed.VideoID, nil, nil
		} else if embed.ImageID != nil {
			return embed.ImageID, nil, nil
		} else if embed.URL != nil {
			if RESnippetableUrl.MatchString(*embed.URL) {
				return nil, embed.URL, nil
			}
		}
	}

	return nil, nil, nil
}

var reDiscordMessageLink = regexp.MustCompile(`https?://.+?(\s|$)`)

func messageHasLinks(content string) bool {
	links := reDiscordMessageLink.FindAllString(content, -1)
	for _, link := range links {
		_, err := url.Parse(strings.TrimSpace(link))
		if err == nil {
			return true
		}
	}

	return false
}
