package handlers

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"warpbox.dev/backend/libs/helpers"
	"warpbox.dev/backend/libs/services"
	"warpbox.dev/backend/libs/web"
)

type downloadPageData struct {
	Box           boxView
	Files         []fileView
	ZipURL        string
	Locked        bool
	Obfuscated    bool
	CanPreview    bool
	DownloadCount int
	MaxDownloads  int
	ExpiresLabel  string
	EmojiTabs     []emojiTabView
}

type boxView struct {
	ID string
}

type fileView struct {
	ID           string
	Name         string
	Size         string
	ContentType  string
	PreviewKind  string
	URL          string
	DownloadURL  string
	ThumbnailURL string
	HasThumbnail bool
	IconURL      string
	IconRetroURL string
	ReactURL     string
	Reactions    []reactionView
	ReactionMore int
	Reacted      bool
}

type reactionView struct {
	EmojiID string `json:"emojiId"`
	URL     string `json:"url"`
	Label   string `json:"label"`
	Count   int    `json:"count"`
	Visible bool   `json:"visible"`
}

type emojiTabView struct {
	ID     string
	Label  string
	Emojis []emojiOptionView
}

type emojiOptionView struct {
	ID    string `json:"id"`
	URL   string `json:"url"`
	Label string `json:"label"`
}

type previewPageData struct {
	Box         boxView
	File        fileView
	Locked      bool
	DownloadURL string
}

func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
	box, err := a.uploadService.GetBox(r.PathValue("boxID"))
	if err != nil {
		a.logger.Warn("download page missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"))...)
		http.NotFound(w, r)
		return
	}
	if err := a.uploadService.CanDownload(box); err != nil {
		a.logger.Warn("download page unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
		a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
			Title:       "Download unavailable",
			Description: "This Warpbox link is no longer available.",
			Data: downloadPageData{
				Box:          boxView{ID: box.ID},
				ExpiresLabel: err.Error(),
			},
		})
		return
	}
	locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
	visitorID := a.reactionVisitorID(w, r)
	reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
	if err != nil {
		a.logger.Warn("failed to load file reactions", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4300, "box_id", box.ID, "error", err.Error())...)
	}

	files := make([]fileView, 0, len(box.Files))
	if !(locked && box.Obfuscate) {
		for _, file := range box.Files {
			files = append(files, a.fileViewWithReactions(box, file, reactionsByFile[file.ID], reactedByFile[file.ID]))
		}
	}
	emojiTabs, err := a.emojiTabs()
	if err != nil {
		a.logger.Warn("failed to load emoji tabs", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4301, "box_id", box.ID, "error", err.Error())...)
	}

	expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
	title := "Shared files on Warpbox"
	description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
	if locked && box.Obfuscate {
		title = "Protected Warpbox link"
		description = "This shared box is password protected."
	}

	a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
		Title:       title,
		Description: description,
		ImageURL:    absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
		Data: downloadPageData{
			Box:           boxView{ID: box.ID},
			Files:         files,
			ZipURL:        fmt.Sprintf("/d/%s/zip", box.ID),
			Locked:        locked,
			Obfuscated:    box.Obfuscate,
			DownloadCount: box.DownloadCount,
			MaxDownloads:  box.MaxDownloads,
			ExpiresLabel:  expiresLabel,
			EmojiTabs:     emojiTabs,
		},
	})
	a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
}

func plural(n int) string {
	if n == 1 {
		return ""
	}
	return "s"
}

func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
	box, file, ok := a.loadFileForRequest(w, r)
	if !ok {
		return
	}

	locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
	view := a.fileView(box, file)
	title := file.Name
	description := fmt.Sprintf("%s shared via Warpbox", helpers.FormatBytes(file.Size))
	imageURL := absoluteURL(r, view.ThumbnailURL)
	if locked && box.Obfuscate {
		title = "Protected Warpbox file"
		description = "This shared file is password protected."
		imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
	}

	a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
		Title:       title,
		Description: description,
		ImageURL:    imageURL,
		Data: previewPageData{
			Box:         boxView{ID: box.ID},
			File:        view,
			Locked:      locked,
			DownloadURL: view.DownloadURL,
		},
	})
	a.logger.Info("file preview page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID)...)
}

func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
	box, file, ok := a.loadFileForRequest(w, r)
	if !ok {
		return
	}
	if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
		a.logger.Warn("protected file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID)...)
		http.Error(w, "password required", http.StatusUnauthorized)
		return
	}

	a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
	a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...)
}

func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
	box, file, ok := a.loadFileForRequest(w, r)
	if !ok {
		return
	}
	if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
		a.servePlaceholderThumbnail(w, r)
		return
	}

	object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
	if err != nil {
		// The thumbnail isn't generated yet (background job pending). Serve the
		// placeholder but mark it non-cacheable, otherwise the browser would
		// keep showing the placeholder until a hard refresh once the real
		// thumbnail lands. The real thumbnail below is content-stable, so it
		// gets a long immutable cache.
		a.servePlaceholderThumbnail(w, r)
		return
	}
	defer object.Body.Close()
	w.Header().Set("Content-Type", "image/jpeg")
	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
	http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
}

// servePlaceholderThumbnail serves the fallback image with no-store so the
// browser re-requests on the next load and picks up the real thumbnail as soon
// as it has been generated.
func (a *App) servePlaceholderThumbnail(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store, must-revalidate")
	http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
}

func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
	box, err := a.uploadService.GetBox(r.PathValue("boxID"))
	if err != nil {
		http.NotFound(w, r)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
		return
	}
	if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
		a.logger.Warn("box unlock failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)...)
		http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     unlockCookieName(box.ID),
		Value:    a.uploadService.UnlockToken(box),
		Path:     "/d/" + box.ID,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   r.TLS != nil,
		Expires:  box.ExpiresAt,
	})
	a.logger.Info("box unlocked", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)...)
	http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
}

func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
	box, err := a.uploadService.GetBox(r.PathValue("boxID"))
	if err != nil {
		a.logger.Warn("file request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"))...)
		http.NotFound(w, r)
		return services.Box{}, services.File{}, false
	}
	if err := a.uploadService.CanDownload(box); err != nil {
		a.logger.Warn("file request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "error", err.Error())...)
		http.Error(w, err.Error(), statusForDownloadError(err))
		return services.Box{}, services.File{}, false
	}

	file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
	if err != nil {
		a.logger.Warn("file request missing file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"))...)
		http.NotFound(w, r)
		return services.Box{}, services.File{}, false
	}
	return box, file, true
}

func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
	object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
	if err != nil {
		a.logger.Warn("file object missing", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
		http.NotFound(w, r)
		return
	}
	defer object.Body.Close()

	w.Header().Set("Content-Type", file.ContentType)
	if attachment {
		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
	}
	if seeker, ok := object.Body.(io.ReadSeeker); ok {
		http.ServeContent(w, r, file.Name, object.ModTime, seeker)
	} else {
		if object.Size > 0 {
			w.Header().Set("Content-Length", fmt.Sprintf("%d", object.Size))
		}
		w.WriteHeader(http.StatusOK)
		_, _ = io.Copy(w, object.Body)
	}

	if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
		a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error())
	}
}

func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
	data, err := io.ReadAll(source)
	if err != nil {
		return bytes.NewReader(nil)
	}
	return bytes.NewReader(data)
}

func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
	box, err := a.uploadService.GetBox(r.PathValue("boxID"))
	if err != nil {
		a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
		http.NotFound(w, r)
		return
	}
	if err := a.uploadService.CanDownload(box); err != nil {
		a.logger.Warn("zip request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
		http.Error(w, err.Error(), statusForDownloadError(err))
		return
	}
	if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
		a.logger.Warn("protected zip download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID)...)
		http.Error(w, "password required", http.StatusUnauthorized)
		return
	}

	w.Header().Set("Content-Type", "application/zip")
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
	w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))

	if err := a.uploadService.WriteZip(w, box); err != nil {
		a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
		return
	}
	if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
		a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
	}
	a.logger.Info("zip downloaded", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "files", len(box.Files))...)
}

func (a *App) fileView(box services.Box, file services.File) fileView {
	return a.fileViewWithReactions(box, file, nil, false)
}

func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView {
	icon := a.fileIcons.lookup(file.Name, file.ContentType)
	reactionViews := a.reactionViews(reactions)
	return fileView{
		ID:           file.ID,
		Name:         file.Name,
		Size:         helpers.FormatBytes(file.Size),
		ContentType:  file.ContentType,
		PreviewKind:  file.PreviewKind,
		URL:          fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
		DownloadURL:  fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
		ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
		HasThumbnail: file.Thumbnail != "",
		IconURL:      fileIconURL("standard", icon.Standard),
		IconRetroURL: fileIconURL("retro", icon.Retro),
		ReactURL:     fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
		Reactions:    reactionViews,
		ReactionMore: reactionOverflowCount(reactionViews),
		Reacted:      reacted,
	}
}

func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) {
	box, file, ok := a.loadFileForRequest(w, r)
	if !ok {
		return
	}
	if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
		http.Error(w, "password required", http.StatusUnauthorized)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid reaction", http.StatusBadRequest)
		return
	}
	emojiID := strings.TrimSpace(r.FormValue("emoji_id"))
	if !a.validEmojiID(emojiID) {
		http.Error(w, "unknown emoji", http.StatusBadRequest)
		return
	}

	visitorID := a.reactionVisitorID(w, r)
	reactions, err := a.reactionService.Add(box.ID, file.ID, visitorID, emojiID)
	if errors.Is(err, os.ErrExist) {
		writeJSON(w, http.StatusConflict, map[string]any{"error": "already reacted"})
		return
	}
	if err != nil {
		a.logger.Warn("file reaction failed", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4302, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
		http.Error(w, "could not save reaction", http.StatusInternalServerError)
		return
	}

	a.logger.Info("file reaction added", withRequestLogAttrs(r, "source", "reactions", "severity", "user_activity", "code", 2301, "box_id", box.ID, "file_id", file.ID, "emoji_id", emojiID)...)
	writeJSON(w, http.StatusCreated, map[string]any{
		"reactions": a.reactionViews(reactions),
		"reacted":   true,
	})
}

func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
	views := make([]reactionView, 0, len(reactions))
	for index, reaction := range reactions {
		views = append(views, reactionView{
			EmojiID: reaction.EmojiID,
			URL:     emojiURL(reaction.EmojiID),
			Label:   emojiLabel(reaction.EmojiID),
			Count:   reaction.Count,
			Visible: index < 2,
		})
	}
	return views
}

func reactionOverflowCount(reactions []reactionView) int {
	if len(reactions) <= 2 {
		return 0
	}
	return len(reactions) - 2
}

func (a *App) emojiTabs() ([]emojiTabView, error) {
	root := a.emojiRoot()
	entries, err := os.ReadDir(root)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, err
	}
	tabs := make([]emojiTabView, 0, len(entries))
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		tabID := entry.Name()
		files, err := os.ReadDir(filepath.Join(root, tabID))
		if err != nil {
			return nil, err
		}
		tab := emojiTabView{ID: tabID, Label: emojiTabLabel(tabID)}
		for _, file := range files {
			if file.IsDir() || !isEmojiFile(file.Name()) {
				continue
			}
			emojiID := tabID + "/" + file.Name()
			tab.Emojis = append(tab.Emojis, emojiOptionView{
				ID:    emojiID,
				URL:   emojiURL(emojiID),
				Label: emojiLabel(emojiID),
			})
		}
		sort.Slice(tab.Emojis, func(i, j int) bool { return tab.Emojis[i].ID < tab.Emojis[j].ID })
		if len(tab.Emojis) > 0 {
			tabs = append(tabs, tab)
		}
	}
	sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
	return tabs, nil
}

func (a *App) validEmojiID(id string) bool {
	id = strings.TrimSpace(id)
	if id == "" || strings.Contains(id, "\\") || strings.Contains(id, "..") || strings.HasPrefix(id, "/") {
		return false
	}
	parts := strings.Split(id, "/")
	if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !isEmojiFile(parts[1]) {
		return false
	}
	info, err := os.Stat(filepath.Join(a.emojiRoot(), parts[0], parts[1]))
	return err == nil && !info.IsDir()
}

func (a *App) emojiRoot() string {
	return filepath.Join(a.cfg.DataDir, "emoji")
}

func (a *App) reactionVisitorID(w http.ResponseWriter, r *http.Request) string {
	const cookieName = "warpbox_reactor"
	if cookie, err := r.Cookie(cookieName); err == nil && strings.TrimSpace(cookie.Value) != "" {
		return cookie.Value
	}
	visitorID := services.RandomPublicToken(32)
	http.SetCookie(w, &http.Cookie{
		Name:     cookieName,
		Value:    visitorID,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   r.TLS != nil,
		Expires:  time.Now().AddDate(1, 0, 0),
	})
	return visitorID
}

func isEmojiFile(name string) bool {
	ext := strings.ToLower(filepath.Ext(name))
	return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
}

func emojiTabLabel(id string) string {
	label := strings.NewReplacer("-", " ", "_", " ").Replace(id)
	if label == "" {
		return "Emoji"
	}
	return strings.ToUpper(label[:1]) + label[1:]
}

func emojiLabel(id string) string {
	base := strings.TrimSuffix(filepath.Base(id), filepath.Ext(id))
	return strings.ReplaceAll(base, "-", " ")
}

func emojiURL(id string) string {
	parts := strings.Split(id, "/")
	if len(parts) != 2 {
		return ""
	}
	return "/emoji/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1])
}

func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(value)
}

func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
	if !a.uploadService.IsProtected(box) {
		return true
	}
	cookie, err := r.Cookie(unlockCookieName(box.ID))
	if err != nil {
		return false
	}
	return cookie.Value == a.uploadService.UnlockToken(box)
}

func unlockCookieName(boxID string) string {
	return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
}

// neverExpires reports whether a box's expiry is far enough out to be treated as
// "forever" (set via the unlimited / -1 expiry option).
func neverExpires(t time.Time) bool {
	return time.Until(t) > 50*365*24*time.Hour
}

// boxExpiryLabel formats a box's expiry with the given layout, rendering
// "forever" boxes as "Never" instead of a meaningless far-future date.
func boxExpiryLabel(t time.Time, layout string) string {
	if neverExpires(t) {
		return "Never"
	}
	return t.Format(layout)
}

func absoluteURL(r *http.Request, path string) string {
	if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
		return path
	}
	scheme := "http"
	if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
		scheme = "https"
	}
	return fmt.Sprintf("%s://%s%s", scheme, r.Host, path)
}
