// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later

package repo

import (
	"bytes"
	"encoding/hex"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"net/http"
	"strconv"
	"strings"
	"time"

	"forgejo.org/models/db"
	issues_model "forgejo.org/models/issues"
	repo_model "forgejo.org/models/repo"
	unit_model "forgejo.org/models/unit"
	user_model "forgejo.org/models/user"
	"forgejo.org/modules/cache"
	"forgejo.org/modules/card"
	"forgejo.org/modules/log"
	"forgejo.org/modules/setting"
	"forgejo.org/modules/storage"
	"forgejo.org/services/context"
)

// drawUser draws a user avatar in a summary card
func drawUser(ctx *context.Context, card *card.Card, user *user_model.User) error {
	if user.UseCustomAvatar {
		posterAvatarPath := user.CustomAvatarRelativePath()
		if posterAvatarPath != "" {
			userAvatarFile, err := storage.Avatars.Open(user.CustomAvatarRelativePath())
			if err != nil {
				return err
			}
			userAvatarImage, _, err := image.Decode(userAvatarFile)
			if err != nil {
				return err
			}
			card.DrawImage(userAvatarImage)
		}
	} else {
		posterAvatarLink := user.AvatarLinkWithSize(ctx, 256)
		card.DrawExternalImage(posterAvatarLink)
	}
	return nil
}

// drawRepoIcon draws the repo icon in a summary card
func drawRepoIcon(ctx *context.Context, card *card.Card, repo *repo_model.Repository) error {
	repoAvatarPath := repo.CustomAvatarRelativePath()

	if repoAvatarPath != "" {
		repoAvatarFile, err := storage.RepoAvatars.Open(repoAvatarPath)
		if err != nil {
			return err
		}
		repoAvatarImage, _, err := image.Decode(repoAvatarFile)
		if err != nil {
			return err
		}
		card.DrawImage(repoAvatarImage)
		return nil
	}

	// If the repo didn't have an avatar, fallback to the repo owner's avatar for the right-hand-side icon
	err := repo.LoadOwner(ctx)
	if err != nil {
		return err
	}
	if repo.Owner != nil {
		err = drawUser(ctx, card, repo.Owner)
		if err != nil {
			return err
		}
	}

	return nil
}

// hexToColor converts a hex color to a go color
func hexToColor(colorStr string) (*color.RGBA, error) {
	colorStr = strings.TrimLeft(colorStr, "#")

	b, err := hex.DecodeString(colorStr)
	if err != nil {
		return nil, err
	}

	if len(b) < 3 {
		return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b))
	}

	color := color.RGBA{b[0], b[1], b[2], 255}

	return &color, nil
}

func drawLanguagesCard(ctx *context.Context, card *card.Card) error {
	languageList, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5)
	if err != nil {
		return err
	}
	if len(languageList) == 0 {
		card.DrawRect(0, 0, card.Width, card.Height, color.White)
		return nil
	}

	currentX := 0
	var langColor *color.RGBA

	for _, lang := range languageList {
		langColor, err = hexToColor(lang.Color)
		if err != nil {
			return err
		}

		langWidth := float32(card.Width) * (lang.Percentage / 100)
		card.DrawRect(currentX, 0, currentX+int(langWidth), card.Width, langColor)
		currentX += int(langWidth)
	}

	if currentX < card.Width {
		card.DrawRect(currentX, 0, card.Width, card.Height, langColor)
	}

	return nil
}

func drawRepoSummaryCard(ctx *context.Context, repo *repo_model.Repository) (*card.Card, error) {
	width, height := card.DefaultSize()
	mainCard, err := card.NewCard(width, height)
	if err != nil {
		return nil, err
	}

	contentCard, languageBarCard := mainCard.Split(false, 90)

	contentCard.SetMargin(60)
	topSection, bottomSection := contentCard.Split(false, 75)
	issueSummary, issueIcon := topSection.Split(true, 80)
	repoInfo, issueDescription := issueSummary.Split(false, 30)

	repoInfo.SetMargin(10)
	_, err = repoInfo.DrawText(repo.FullName(), color.Black, 56, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	issueDescription.SetMargin(10)
	// Replace new lines with spaces to match repo description in-app rendering
	issueDescriptionText := strings.NewReplacer(
		"\r\n", " ",
		"\r", " ",
		"\n", " ",
	).Replace(repo.Description)
	_, err = issueDescription.DrawText(issueDescriptionText, color.Gray{128}, 36, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	issueIcon.SetMargin(10)
	err = drawRepoIcon(ctx, issueIcon, repo)
	if err != nil {
		return nil, err
	}

	topCountCard, bottomCountCard := bottomSection.Split(false, 50)

	releaseCount, err := db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{
		// only show draft releases for users who can write, read-only users shouldn't see draft releases.
		IncludeDrafts: ctx.Repo.CanWrite(unit_model.TypeReleases),
		RepoID:        ctx.Repo.Repository.ID,
	})
	if err != nil {
		return nil, err
	}

	starsText := ctx.Locale.TrPluralString(
		repo.NumStars,
		"stars.n_stars",
		strconv.Itoa(repo.NumStars),
	)
	forksText := ctx.Locale.TrPluralString(
		repo.NumForks,
		"fork.n_forks",
		strconv.Itoa(repo.NumForks),
	)
	releasesText := ctx.Locale.TrN(
		releaseCount,
		"repo.activity.title.releases_1",
		"repo.activity.title.releases_n",
		releaseCount,
	)

	topCountText := fmt.Sprintf("%s • %s • %s", starsText, forksText, releasesText)

	topCountCard.SetMargin(10)
	_, err = topCountCard.DrawText(topCountText, color.Gray{128}, 36, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	issuesText := ctx.Locale.TrN(
		repo.NumOpenIssues(ctx),
		"repo.activity.title.issues_1",
		"repo.activity.title.issues_n",
		repo.NumOpenIssues(ctx),
	)
	pullRequestsText := ctx.Locale.TrN(
		repo.NumOpenPulls(ctx),
		"repo.activity.title.prs_1",
		"repo.activity.title.prs_n",
		repo.NumOpenPulls(ctx),
	)

	bottomCountText := fmt.Sprintf("%s • %s", issuesText, pullRequestsText)

	bottomCountCard.SetMargin(10)
	_, err = bottomCountCard.DrawText(bottomCountText, color.Gray{128}, 36, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	err = drawLanguagesCard(ctx, languageBarCard)
	if err != nil {
		return nil, err
	}

	return mainCard, nil
}

func drawIssueSummaryCard(ctx *context.Context, issue *issues_model.Issue) (*card.Card, error) {
	width, height := card.DefaultSize()
	mainCard, err := card.NewCard(width, height)
	if err != nil {
		return nil, err
	}

	mainCard.SetMargin(60)
	topSection, bottomSection := mainCard.Split(false, 75)
	issueSummary, issueIcon := topSection.Split(true, 80)
	repoInfo, issueDescription := issueSummary.Split(false, 15)

	repoInfo.SetMargin(10)
	_, err = repoInfo.DrawText(fmt.Sprintf("%s - #%d", issue.Repo.FullName(), issue.Index), color.Gray{128}, 36, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	issueDescription.SetMargin(10)
	_, err = issueDescription.DrawText(issue.Title, color.Black, 56, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	issueIcon.SetMargin(10)
	err = drawRepoIcon(ctx, issueIcon, issue.Repo)
	if err != nil {
		return nil, err
	}

	issueStats, issueAttribution := bottomSection.Split(false, 50)

	var state string
	if issue.IsPull && issue.PullRequest.HasMerged {
		if issue.PullRequest.Status == 3 {
			state = ctx.Locale.TrString("repo.pulls.manually_merged")
		} else {
			state = ctx.Locale.TrString("repo.pulls.merged")
		}
	} else if issue.IsClosed {
		state = ctx.Locale.TrString("repo.issues.closed_title")
	} else if issue.IsPull {
		if issue.PullRequest.IsWorkInProgress(ctx) {
			state = ctx.Locale.TrString("repo.issues.draft_title")
		} else {
			state = ctx.Locale.TrString("repo.issues.open_title")
		}
	} else {
		state = ctx.Locale.TrString("repo.issues.open_title")
	}
	state = strings.ToLower(state)

	issueStats.SetMargin(10)
	if issue.IsPull {
		reviews := map[int64]bool{}
		for _, comment := range issue.Comments {
			if comment.Review != nil {
				reviews[comment.Review.ID] = true
			}
		}
		_, err = issueStats.DrawText(
			fmt.Sprintf("%s, %s, %s",
				ctx.Locale.TrN(
					issue.NumComments,
					"repo.issues.num_comments_1",
					"repo.issues.num_comments",
					issue.NumComments,
				),
				ctx.Locale.TrN(
					len(reviews),
					"repo.issues.num_reviews_one",
					"repo.issues.num_reviews_few",
					len(reviews),
				),
				state,
			),
			color.Gray{128}, 36, card.Top, card.Left)
	} else {
		_, err = issueStats.DrawText(
			fmt.Sprintf("%s, %s",
				ctx.Locale.TrN(
					issue.NumComments,
					"repo.issues.num_comments_1",
					"repo.issues.num_comments",
					issue.NumComments,
				),
				state,
			),
			color.Gray{128}, 36, card.Top, card.Left)
	}
	if err != nil {
		return nil, err
	}

	issueAttributionIcon, issueAttributionText := issueAttribution.Split(true, 8)
	issueAttributionText.SetMargin(5)
	_, err = issueAttributionText.DrawText(
		fmt.Sprintf(
			"%s - %s",
			issue.Poster.Name,
			issue.CreatedUnix.AsTime().Format(time.DateOnly),
		),
		color.Gray{128}, 36, card.Middle, card.Left)
	if err != nil {
		return nil, err
	}
	err = drawUser(ctx, issueAttributionIcon, issue.Poster)
	if err != nil {
		return nil, err
	}

	return mainCard, nil
}

func drawReleaseSummaryCard(ctx *context.Context, release *repo_model.Release) (*card.Card, error) {
	width, height := card.DefaultSize()
	mainCard, err := card.NewCard(width, height)
	if err != nil {
		return nil, err
	}

	mainCard.SetMargin(60)
	topSection, bottomSection := mainCard.Split(false, 75)
	releaseSummary, repoIcon := topSection.Split(true, 80)
	repoInfo, releaseDescription := releaseSummary.Split(false, 15)

	repoInfo.SetMargin(10)
	_, err = repoInfo.DrawText(release.Repo.FullName(), color.Gray{128}, 36, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	releaseDescription.SetMargin(10)
	_, err = releaseDescription.DrawText(release.DisplayName(), color.Black, 56, card.Top, card.Left)
	if err != nil {
		return nil, err
	}

	repoIcon.SetMargin(10)
	err = drawRepoIcon(ctx, repoIcon, release.Repo)
	if err != nil {
		return nil, err
	}

	downloadCountCard, releaseDateCard := bottomSection.Split(true, 75)

	downloadCount, err := release.GetTotalDownloadCount(ctx)
	if err != nil {
		return nil, err
	}

	downloadCountText := ctx.Locale.TrPluralString(downloadCount,
		"release.n_downloads", strconv.FormatInt(downloadCount, 10))

	_, err = downloadCountCard.DrawText(string(downloadCountText), color.Gray{128}, 36, card.Bottom, card.Left)
	if err != nil {
		return nil, err
	}

	_, err = releaseDateCard.DrawText(release.CreatedUnix.AsTime().Format(time.DateOnly), color.Gray{128}, 36, card.Bottom, card.Left)
	if err != nil {
		return nil, err
	}

	return mainCard, nil
}

// checkCardCache checks if a card in cache and serves it
func checkCardCache(ctx *context.Context, cacheKey string) bool {
	cache := cache.GetCache()
	pngData, ok := cache.Get(cacheKey).([]byte)
	if ok && pngData != nil && len(pngData) > 0 {
		ctx.Resp.Header().Set("Content-Type", "image/png")
		ctx.Resp.WriteHeader(http.StatusOK)
		_, err := ctx.Resp.Write(pngData)
		if err != nil {
			ctx.ServerError("GetSummaryCard", err)
		}
		return true
	}

	return false
}

// serveCard server a Card to the user adds it to the cache
func serveCard(ctx *context.Context, card *card.Card, cacheKey string) {
	cache := cache.GetCache()

	// Encode image, store in cache
	var imageBuffer bytes.Buffer
	err := png.Encode(&imageBuffer, card.Img)
	if err != nil {
		ctx.ServerError("GetSummaryCard", err)
		return
	}
	imageBytes := imageBuffer.Bytes()
	err = cache.Put(cacheKey, imageBytes, setting.CacheService.TTLSeconds())
	if err != nil {
		// don't abort serving the image if we just had a cache storage failure
		log.Warn("failed to cache issue summary card: %v", err)
	}

	// Finish the uncached image response
	ctx.Resp.Header().Set("Content-Type", "image/png")
	ctx.Resp.WriteHeader(http.StatusOK)
	_, err = ctx.Resp.Write(imageBytes)
	if err != nil {
		ctx.ServerError("GetSummaryCard", err)
		return
	}
}

func DrawRepoSummaryCard(ctx *context.Context) {
	cacheKey := fmt.Sprintf("summary_card:repo:%s:%d", ctx.Locale.Language(), ctx.Repo.Repository.ID)

	if checkCardCache(ctx, cacheKey) {
		return
	}

	card, err := drawRepoSummaryCard(ctx, ctx.Repo.Repository)
	if err != nil {
		ctx.ServerError("drawRepoSummaryCar", err)
		return
	}

	serveCard(ctx, card, cacheKey)
}

func DrawIssueSummaryCard(ctx *context.Context) {
	issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
	if err != nil {
		if issues_model.IsErrIssueNotExist(err) {
			ctx.Error(http.StatusNotFound)
		} else {
			ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error())
		}
		return
	}

	if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) {
		ctx.Error(http.StatusNotFound)
		return
	}

	cacheKey := fmt.Sprintf("summary_card:issue:%s:%d", ctx.Locale.Language(), issue.ID)

	if checkCardCache(ctx, cacheKey) {
		return
	}

	card, err := drawIssueSummaryCard(ctx, issue)
	if err != nil {
		ctx.ServerError("drawIssueSummaryCar", err)
		return
	}

	serveCard(ctx, card, cacheKey)
}

func DrawReleaseSummaryCard(ctx *context.Context) {
	release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, ctx.Params("*"))
	if err != nil {
		if repo_model.IsErrReleaseNotExist(err) {
			ctx.NotFound("", nil)
		} else {
			ctx.ServerError("GetReleaseForRepoByID", err)
		}
		return
	}

	err = release.LoadRepo(ctx)
	if err != nil {
		ctx.ServerError("LoadRepo", err)
		return
	}

	cacheKey := fmt.Sprintf("summary_card:release:%s:%d", ctx.Locale.Language(), release.ID)

	if checkCardCache(ctx, cacheKey) {
		return
	}

	card, err := drawReleaseSummaryCard(ctx, release)
	if err != nil {
		ctx.ServerError("drawRepoSummaryCar", err)
		return
	}

	serveCard(ctx, card, cacheKey)
}
