// Package api — media endpoints serve raw bytes (cover art, audio streams).
// The helpers below duplicate shape with internal/subsonic/stream.go by
// design; the two packages are kept independent so subsonic can freeze as a
// compatibility surface while /api evolves. See project memory
// `project_subsonic_legacy.md` for the rationale.

package api

import (
	"context"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/go-chi/chi/v5"

	"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
// local alias so the call sites in this file read naturally.
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
	return coverart.ResolveAlbumPath(ctx, q, album)
}

// audioContentType maps the short file_format recorded on tracks (mp3, flac,
// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
// This is the canonical table; both the browser stream endpoint and the
// UPnP cast token URL builder consult it. Unknown formats fall back to
// octet-stream so the browser downloads them rather than attempting to
// decode -- cast_token.go applies its own audio/mpeg fallback for Sonos.
//
// Aliases (mpeg/vorbis/wave) cover historical / alternate format spellings
// that have shown up in track rows. The trim+lowercase normalization makes
// the lookup permissive to whatever a scanner happened to write.
//
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
// intentional: opus/vorbis→audio/ogg (library .opus / .ogg files are
// Ogg-encapsulated, so this matches real library contents), aac→audio/aac
// (raw AAC is ADTS, not MP4, so audio/mp4 would mislead codec sniffers),
// and there is no "oga" case (we don't record that format). Subsonic is a
// frozen client contract -- don't "fix" these to match it.
func audioContentType(format string) string {
	switch strings.ToLower(strings.TrimSpace(format)) {
	case "mp3", "mpeg":
		return "audio/mpeg"
	case "flac":
		return "audio/flac"
	case "ogg", "opus", "vorbis":
		return "audio/ogg"
	case "m4a", "mp4":
		return "audio/mp4"
	case "aac":
		return "audio/aac"
	case "wav", "wave":
		return "audio/wav"
	}
	return "application/octet-stream"
}

// imageContentType maps a file extension to a MIME type for cover art.
// Unknown extensions fall back to octet-stream.
func imageContentType(path string) string {
	switch strings.ToLower(filepath.Ext(path)) {
	case ".jpg", ".jpeg":
		return "image/jpeg"
	case ".png":
		return "image/png"
	case ".webp":
		return "image/webp"
	case ".gif":
		return "image/gif"
	}
	return "application/octet-stream"
}

// handleGetCover implements GET /api/albums/{id}/cover. Resolves the cover
// path (explicit column, else sidecar next to first track), then delegates
// byte-serving to http.ServeContent so clients get Range / If-Modified-Since
// / ETag for free.
func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
	q := dbq.New(h.pool)
	album, apiErr := resolveByID(r, "id", q.GetAlbumByID, "album")
	if apiErr != nil {
		writeErr(w, apiErr)
		return
	}
	path := resolveAlbumCoverPath(r.Context(), q, album)
	if path == "" {
		writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "cover not found"})
		return
	}
	f, err := os.Open(path)
	if err != nil {
		// resolveAlbumCoverPath saw the file a moment ago; if it vanished
		// between stat and open, treat it the same as no-art — the user
		// experience (404) matches, and 500 would misleadingly imply a server
		// bug rather than a filesystem race.
		writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "cover not found"})
		return
	}
	defer func() { _ = f.Close() }()
	info, err := f.Stat()
	if err != nil {
		h.logger.Error("api: stat cover failed", "err", err)
		writeErr(w, apierror.InternalMsg("server error", err))
		return
	}
	w.Header().Set("Content-Type", imageContentType(path))
	// Cover bytes change rarely (cover-source enrichment, manual rescan,
	// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
	// clients skip the conditional GET for the bulk of a session, but
	// stale art clears within 24h after a re-scan. ServeContent below
	// still emits Last-Modified for the conditional path when needed.
	w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
	http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}

// streamAuthOk returns true if the request is authorized to fetch the
// stream — either via the standard session path (cookie OR bearer token
// resolved to a user-in-context by auth.OptionalUser, the middleware
// the route is wrapped with in api.go) OR via a valid short-lived HMAC
// token in the ?token=&exp= query (UPnP / Sonos path, new for the
// output-picker UPnP slice).
//
// The token path lets network speakers fetch the stream URL without
// carrying the user's session cookie — they cannot. See the design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
//
// When h.streamSecret is nil (slice-1 default, until slice 2 wires the
// loader), the token path always rejects because the HMAC of an empty
// key won't match any token a client could mint. The session path keeps
// working.
func (h *handlers) streamAuthOk(r *http.Request, trackID string) bool {
	if _, ok := auth.UserFromContext(r.Context()); ok {
		return true
	}
	tok := r.URL.Query().Get("token")
	expStr := r.URL.Query().Get("exp")
	if tok == "" || expStr == "" {
		return false
	}
	exp, err := strconv.ParseInt(expStr, 10, 64)
	if err != nil {
		return false
	}
	return VerifyStreamToken(h.streamSecret, trackID, exp, tok)
}

// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
// disk and delegates byte-serving to http.ServeContent, which handles Range,
// If-Modified-Since, and ETag based on the file's mod time.
//
// The route lives outside the authed.Group so this handler can accept
// EITHER a session-resolved user (attached by auth.OptionalUser, the
// permissive middleware the route is wrapped with) OR a signed token
// (UPnP slice). streamAuthOk gates both paths.
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
	// Auth check before the DB lookup: a 404 ahead of the auth check
	// would let unauth callers probe which track IDs exist via the
	// 404/401 response differential. streamAuthOk is keyed on the
	// path's id directly (the HMAC token is signed over the same id
	// string, so we don't need the resolved row yet).
	rawID := chi.URLParam(r, "id")
	if !h.streamAuthOk(r, rawID) {
		writeErr(w, apierror.ErrUnauthorized)
		return
	}
	track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
	if apiErr != nil {
		writeErr(w, apiErr)
		return
	}
	f, err := os.Open(track.FilePath)
	if err != nil {
		// File vanished (scanner indexed it, filesystem lost it). Treat as
		// 404 so clients can fall back to the next item in a queue.
		writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track file not found"})
		return
	}
	defer func() { _ = f.Close() }()
	info, err := f.Stat()
	if err != nil {
		h.logger.Error("api: stat track failed", "err", err)
		writeErr(w, apierror.InternalMsg("server error", err))
		return
	}
	w.Header().Set("Content-Type", audioContentType(track.FileFormat))
	w.Header().Set("Accept-Ranges", "bytes")
	// Track bytes are immutable for a given track id (the scanner
	// indexes by file_path; re-encoded files take new ids). One-year
	// max-age + immutable lets the client cache (LockCachingAudioSource
	// on the Flutter side, browser cache on web) skip even the
	// conditional GET on repeat plays.
	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
