package httpapi

import (
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
	"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)

const presignExpiry = 15 * time.Minute

// Sizes the download endpoint offers. Preview is the default because it is
// what a phone can actually save to its photo library: iOS and Android only
// reach the gallery through the share sheet, and the share sheet is worth
// handing a 2048px JPEG rather than a multi-megabyte original.
const (
	downloadSizePreview  = "preview"
	downloadSizeOriginal = "original"
)

// serveFile delivers photo bytes after re-running the gallery access check.
// S3: 302 to a short-lived presigned URL; local: streamed directly.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
	variant := r.PathValue("variant")
	if variant != "thumb" && variant != "preview" && variant != "original" {
		writeError(w, http.StatusNotFound, "Not Found")
		return
	}
	p, _, ok := s.authorizedPhoto(w, r)
	if !ok {
		return
	}

	var key, contentType, downloadName string
	switch variant {
	case "thumb":
		key, contentType = p.ThumbKey, "image/jpeg"
	case "preview":
		key, contentType = p.PreviewKey, "image/jpeg"
	case "original":
		key, contentType = p.OriginalKey, p.ContentType
		downloadName = p.OriginalFilename
		if downloadName == "" {
			downloadName = p.ID + extForContentType(p.ContentType)
		}
	}
	if key == "" {
		// Variant not generated yet (photo still processing).
		writeError(w, http.StatusNotFound, "Not ready")
		return
	}

	if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil {
		http.Redirect(w, r, url, http.StatusFound)
		return
	} else if !errors.Is(err, storage.ErrNoPresign) {
		log.Printf("Error: presign %s: %v", key, err)
		writeError(w, http.StatusInternalServerError, "Internal Server Error")
		return
	}

	reader, size, err := s.storage.Open(r.Context(), key)
	if err != nil {
		if os.IsNotExist(err) {
			writeError(w, http.StatusNotFound, "Not Found")
			return
		}
		log.Printf("Error: open %s: %v", key, err)
		writeError(w, http.StatusInternalServerError, "Internal Server Error")
		return
	}
	defer reader.Close()

	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
	w.Header().Set("X-Content-Type-Options", "nosniff")
	// Keys are immutable (new upload = new key), so private caching is safe
	// even though access is checked per request.
	w.Header().Set("Cache-Control", "private, max-age=86400")
	if downloadName != "" {
		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName))
	}
	if _, err := io.Copy(w, reader); err != nil {
		log.Printf("stream %s: %v", key, err)
	}
}

// serveDownload backs the gallery's save/download actions. It differs from
// serveFile in three ways that the mobile save flow depends on:
//
//   - It always streams. serveFile 302s to S3 when presigning is available,
//     and a cross-origin redirect is opaque to fetch() — the client could
//     never read the bytes into a Blob to hand to navigator.share().
//   - It names the file (spanglish-<event>-<n>.jpg) and always declares a
//     real image/* type, because iOS only offers "Save Image" in the share
//     sheet for something it recognises as an image.
//   - It caches for a year as immutable. ?size=preview is also the URL the
//     lightbox renders, so tapping save re-reads the bytes already on screen
//     out of the HTTP cache instead of fetching them again.
func (s *Server) serveDownload(w http.ResponseWriter, r *http.Request) {
	size := r.URL.Query().Get("size")
	if size == "" {
		size = downloadSizePreview
	}
	if size != downloadSizePreview && size != downloadSizeOriginal {
		writeError(w, http.StatusNotFound, "Not Found")
		return
	}
	p, g, ok := s.authorizedPhoto(w, r)
	if !ok {
		return
	}

	key, contentType := p.PreviewKey, "image/jpeg"
	if size == downloadSizeOriginal {
		key, contentType = p.OriginalKey, imageContentType(p.ContentType, p.OriginalFilename)
	}
	if key == "" {
		writeError(w, http.StatusNotFound, "Not ready")
		return
	}

	reader, n, err := s.storage.Open(r.Context(), key)
	if err != nil {
		if os.IsNotExist(err) || errors.Is(err, storage.ErrNotExist) {
			writeError(w, http.StatusNotFound, "Not Found")
			return
		}
		log.Printf("Error: open %s: %v", key, err)
		writeError(w, http.StatusInternalServerError, "Internal Server Error")
		return
	}
	defer reader.Close()

	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Content-Length", fmt.Sprintf("%d", n))
	w.Header().Set("X-Content-Type-Options", "nosniff")
	// The preview is rendered as an <img> as well as saved, so it is marked
	// inline; only the original is a pure download. Either way the filename
	// travels with it, which is what a bare <a download> fallback and the
	// Android share sheet display.
	disposition := "inline"
	if size == downloadSizeOriginal {
		disposition = "attachment"
	}
	w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q",
		disposition, s.downloadFilename(r, g, p, size)))
	// Objects are immutable (a new upload writes a new key), so the response
	// can be cached for as long as the browser will keep it. Restricted
	// galleries stay out of shared caches: access is re-checked per request
	// here, and only the browser that passed the check may reuse the bytes.
	scope := "private"
	if g.Visibility == store.VisibilityPublic {
		scope = "public"
	}
	w.Header().Set("Cache-Control", scope+", max-age=31536000, immutable")

	if _, err := io.Copy(w, reader); err != nil {
		// Routine on mobile: the client aborts the fetch when the save sheet
		// is cancelled, which lands here as a broken pipe.
		log.Printf("stream %s: %v", key, err)
	}
}

// authorizedPhoto loads the photo and its gallery and re-runs the access
// check every byte-serving handler owes (PLAN.md §7). It writes the error
// response itself; ok=false means the caller is done.
func (s *Server) authorizedPhoto(w http.ResponseWriter, r *http.Request) (store.Photo, store.Gallery, bool) {
	p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
	if err != nil {
		writeStoreError(w, err, "Photo not found")
		return store.Photo{}, store.Gallery{}, false
	}
	g, err := s.db.GetGallery(r.Context(), p.GalleryID)
	if err != nil {
		writeStoreError(w, err, "Photo not found")
		return store.Photo{}, store.Gallery{}, false
	}
	user := s.optionalUser(r)
	if denial := s.authorize(r, g, user, r.URL.Query().Get("token")); denial != nil {
		writeError(w, denial.status, denial.msg)
		return store.Photo{}, store.Gallery{}, false
	}
	// Non-ready originals stay admin-only so uploads that fail processing
	// never leak to viewers.
	if p.Status != "ready" && !(user != nil && user.IsAdmin()) {
		writeError(w, http.StatusNotFound, "Not ready")
		return store.Photo{}, store.Gallery{}, false
	}
	return p, g, true
}

// downloadFilename names the saved file after the event it came from rather
// than the photographer's IMG_1234.JPG: spanglish-<event-slug>-<n>.<ext>,
// numbered from 1 in gallery order. Standalone galleries use their own slug.
func (s *Server) downloadFilename(r *http.Request, g store.Gallery, p store.Photo, size string) string {
	slug := g.Slug
	if g.EventID != "" {
		if ev, err := s.db.GetEventSummary(r.Context(), g.EventID); err == nil && ev.Slug != "" {
			slug = ev.Slug
		}
	}
	ext := ".jpg"
	if size == downloadSizeOriginal {
		if e := extForContentType(p.ContentType); e != "" {
			ext = e
		}
	}
	return fmt.Sprintf("spanglish-%s-%d%s", slug, p.Position+1, ext)
}

// imageContentType keeps the download endpoint's Content-Type in the image/*
// family. An original stored with a missing or generic type would otherwise
// go out as application/octet-stream, and iOS drops "Save Image" from the
// share sheet for anything it cannot see as an image.
func imageContentType(ct, filename string) string {
	if strings.HasPrefix(ct, "image/") {
		return ct
	}
	if i := strings.LastIndex(filename, "."); i >= 0 {
		switch strings.ToLower(filename[i:]) {
		case ".jpg", ".jpeg":
			return "image/jpeg"
		case ".png":
			return "image/png"
		case ".gif":
			return "image/gif"
		case ".webp":
			return "image/webp"
		case ".heic", ".heif":
			return "image/heic"
		}
	}
	return "image/jpeg"
}

func extForContentType(ct string) string {
	switch ct {
	case "image/jpeg":
		return ".jpg"
	case "image/png":
		return ".png"
	case "image/gif":
		return ".gif"
	case "image/webp":
		return ".webp"
	case "image/heic":
		return ".heic"
	}
	return ""
}
