// 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"
	"errors"
	"net/http"
	"os"
	"path/filepath"
	"strings"

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

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

// sidecarNames is the lookup order for cover art living next to audio files.
// Matches common library conventions: cover.* wins over folder.*; JPEG wins
// over PNG when both exist.
var sidecarNames = []string{
	"cover.jpg", "cover.jpeg", "cover.png",
	"folder.jpg", "folder.jpeg", "folder.png",
}

// findSidecarCover looks for a conventional cover image in the directory that
// contains trackPath. Returns "" if no sidecar exists.
func findSidecarCover(trackPath string) string {
	dir := filepath.Dir(trackPath)
	for _, name := range sidecarNames {
		candidate := filepath.Join(dir, name)
		if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
			return candidate
		}
	}
	return ""
}

// resolveAlbumCoverPath returns the filesystem path to the album's cover art.
// It prefers an explicit cover_art_path (set by the scanner in a future
// milestone) and falls back to a sidecar next to the first track in the
// album's directory. "" means no art was found.
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
	if album.CoverArtPath != nil && *album.CoverArtPath != "" {
		if _, err := os.Stat(*album.CoverArtPath); err == nil {
			return *album.CoverArtPath
		}
	}
	tracks, err := q.ListTracksByAlbum(ctx, album.ID)
	if err != nil || len(tracks) == 0 {
		return ""
	}
	return findSidecarCover(tracks[0].FilePath)
}

// 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.
// Unknown formats fall back to octet-stream so the browser downloads them
// rather than attempting to decode.
//
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
// intentional: opus→audio/ogg (library .opus 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). Don't "fix" these to match subsonic.
func audioContentType(format string) string {
	switch strings.ToLower(format) {
	case "mp3":
		return "audio/mpeg"
	case "flac":
		return "audio/flac"
	case "ogg", "opus":
		return "audio/ogg"
	case "m4a":
		return "audio/mp4"
	case "aac":
		return "audio/aac"
	case "wav":
		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) {
	id, ok := parseUUID(chi.URLParam(r, "id"))
	if !ok {
		writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
		return
	}
	q := dbq.New(h.pool)
	album, err := q.GetAlbumByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, http.StatusNotFound, "not_found", "album not found")
			return
		}
		h.logger.Error("api: get album for cover failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "server error")
		return
	}
	path := resolveAlbumCoverPath(r.Context(), q, album)
	if path == "" {
		writeErr(w, http.StatusNotFound, "not_found", "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, http.StatusNotFound, "not_found", "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, http.StatusInternalServerError, "server_error", "server error")
		return
	}
	w.Header().Set("Content-Type", imageContentType(path))
	http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}

// 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.
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
	id, ok := parseUUID(chi.URLParam(r, "id"))
	if !ok {
		writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
		return
	}
	q := dbq.New(h.pool)
	track, err := q.GetTrackByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, http.StatusNotFound, "not_found", "track not found")
			return
		}
		h.logger.Error("api: get track for stream failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "server error")
		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, http.StatusNotFound, "not_found", "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, http.StatusInternalServerError, "server_error", "server error")
		return
	}
	w.Header().Set("Content-Type", audioContentType(track.FileFormat))
	w.Header().Set("Accept-Ranges", "bytes")
	http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
