package subsonic

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

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

// mediaHandlers serves the bytes-on-the-wire endpoints: stream, download,
// getCoverArt, scrobble. Scrobble routes through playevents.Writer so
// Subsonic clients feed the same play_events table as the native /api/events.
type mediaHandlers struct {
	pool   *pgxpool.Pool
	events *playevents.Writer
	logger *slog.Logger
}

func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer, logger *slog.Logger) *mediaHandlers {
	return &mediaHandlers{pool: pool, events: events, logger: logger}
}

// handleStream serves the raw track bytes. http.ServeContent gives us Range,
// If-Modified-Since, and ETag handling for free — the Subsonic spec requires
// Range support so clients can seek.
func (m *mediaHandlers) handleStream(w http.ResponseWriter, r *http.Request) {
	m.serveTrack(w, r, false)
}

// handleDownload is stream with a filename and attachment disposition so
// browsers save rather than stream.
func (m *mediaHandlers) handleDownload(w http.ResponseWriter, r *http.Request) {
	m.serveTrack(w, r, true)
}

func (m *mediaHandlers) serveTrack(w http.ResponseWriter, r *http.Request, attachment bool) {
	idStr := r.URL.Query().Get("id")
	if idStr == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
		return
	}
	id, ok := parseUUID(idStr)
	if !ok {
		WriteFail(w, r, ErrDataNotFound, "Track not found")
		return
	}
	q := dbq.New(m.pool)
	track, err := q.GetTrackByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			WriteFail(w, r, ErrDataNotFound, "Track not found")
			return
		}
		WriteFail(w, r, ErrGeneric, "Failed to load track")
		return
	}

	f, err := os.Open(track.FilePath)
	if err != nil {
		// File vanished between scan and now — treat as not found so clients
		// can recover gracefully rather than assume a 5xx network blip.
		WriteFail(w, r, ErrDataNotFound, "Track file not found")
		return
	}
	defer func() { _ = f.Close() }()

	info, err := f.Stat()
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to stat track file")
		return
	}

	w.Header().Set("Content-Type", contentTypeForFormat(track.FileFormat))
	w.Header().Set("Accept-Ranges", "bytes")
	if attachment {
		w.Header().Set("Content-Disposition",
			fmt.Sprintf(`attachment; filename=%q`, filepath.Base(track.FilePath)))
	}
	http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}

// handleGetCoverArt resolves an id (album, track, or cover-art id — all of
// which we key off the album UUID) to an image file. Scanner does not yet
// populate albums.cover_art_path, so the common path is the sidecar lookup.
func (m *mediaHandlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request) {
	idStr := r.URL.Query().Get("id")
	if idStr == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
		return
	}
	id, ok := parseUUID(idStr)
	if !ok {
		WriteFail(w, r, ErrDataNotFound, "Cover art not found")
		return
	}

	q := dbq.New(m.pool)
	album, albumErr := q.GetAlbumByID(r.Context(), id)
	if albumErr != nil {
		// Fall back: maybe the id is a track. Resolve through its album_id.
		track, terr := q.GetTrackByID(r.Context(), id)
		if terr != nil {
			WriteFail(w, r, ErrDataNotFound, "Cover art not found")
			return
		}
		album, albumErr = q.GetAlbumByID(r.Context(), track.AlbumID)
		if albumErr != nil {
			WriteFail(w, r, ErrDataNotFound, "Cover art not found")
			return
		}
	}

	if path := resolveAlbumCoverPath(r.Context(), q, album); path != "" {
		serveImage(w, r, path)
		return
	}
	WriteFail(w, r, ErrDataNotFound, "Cover art not found")
}

// 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)
}

func serveImage(w http.ResponseWriter, r *http.Request, path string) {
	f, err := os.Open(path)
	if err != nil {
		WriteFail(w, r, ErrDataNotFound, "Cover art not found")
		return
	}
	defer func() { _ = f.Close() }()
	info, err := f.Stat()
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Failed to stat cover art")
		return
	}
	w.Header().Set("Content-Type", imageContentType(path))
	http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}

func imageContentType(path string) string {
	switch strings.ToLower(filepath.Ext(path)) {
	case ".jpg", ".jpeg":
		return "image/jpeg"
	case ".png":
		return "image/png"
	case ".gif":
		return "image/gif"
	case ".webp":
		return "image/webp"
	default:
		return "application/octet-stream"
	}
}

// handleScrobble translates Subsonic scrobble calls into native play_events.
//
// submission=false (now-playing): writes a play_started, leaves ended_at NULL.
// submission=true (completed): writes a synthetic completed play (start+end
// in one transaction). play_events WHERE ended_at IS NULL is the source of
// truth for "currently playing"; the prior in-memory nowPlayingMap is gone.
func (m *mediaHandlers) handleScrobble(w http.ResponseWriter, r *http.Request) {
	params := r.URL.Query()
	idStr := params.Get("id")
	if idStr == "" {
		WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
		return
	}
	trackID, ok := parseUUID(idStr)
	if !ok {
		WriteFail(w, r, ErrDataNotFound, "Track not found")
		return
	}
	user, ok := UserFromContext(r.Context())
	if !ok {
		WriteFail(w, r, ErrGeneric, "Unauthenticated")
		return
	}
	at := time.Now().UTC()
	if t := params.Get("time"); t != "" {
		// Subsonic spec uses ms-since-epoch; some clients send ISO 8601.
		// Accept both for resilience.
		if ms, err := strconv.ParseInt(t, 10, 64); err == nil {
			at = time.UnixMilli(ms).UTC()
		} else if parsed, err := time.Parse(time.RFC3339, t); err == nil {
			at = parsed
		}
	}
	clientID := params.Get("c")

	// Subsonic default is submission=true (completed play).
	submission := strings.ToLower(params.Get("submission"))
	switch submission {
	case "true", "":
		if err := m.events.RecordSyntheticCompletedPlay(r.Context(), user.ID, trackID, clientID, at); err != nil {
			WriteFail(w, r, ErrGeneric, "Could not record play")
			return
		}
	case "false":
		if _, err := m.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at); err != nil {
			WriteFail(w, r, ErrGeneric, "Could not record now-playing")
			return
		}
	default:
		// Unknown submission values: ack and ignore.
	}
	Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
}
