package api

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

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

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

// playlistRowView is the wire shape for a playlist header. Mirrors
// playlists.PlaylistRow but renders UUIDs / timestamps as strings and
// derives the cover_url from cover_path so clients don't have to know
// the on-disk layout.
type playlistRowView struct {
	ID            string  `json:"id"`
	UserID        string  `json:"user_id"`
	OwnerUsername string  `json:"owner_username"`
	Name          string  `json:"name"`
	Description   string  `json:"description"`
	IsPublic      bool    `json:"is_public"`
	Kind          string  `json:"kind"`
	SystemVariant *string `json:"system_variant,omitempty"`
	// Refreshable: a singleton system kind addressable by the generic
	// /system/{kind}/{refresh,shuffle} endpoints. Lets clients show
	// the per-tile refresh affordance generically without hardcoding
	// which kinds support it (false for user + songs_like_artist).
	Refreshable  bool    `json:"refreshable"`
	SeedArtistID *string `json:"seed_artist_id,omitempty"`
	CoverURL     string  `json:"cover_url,omitempty"`
	TrackCount   int32   `json:"track_count"`
	DurationSec  int32   `json:"duration_sec"`
	CreatedAt    string  `json:"created_at"`
	UpdatedAt    string  `json:"updated_at"`
}

// playlistTrackView is one row of a playlist's track list on the wire.
// TrackID / AlbumID / ArtistID / StreamURL are pointers so they encode as
// JSON null when the upstream library row has been removed (ON DELETE
// SET NULL semantics from the snapshot table).
type playlistTrackView struct {
	Position    int32   `json:"position"`
	TrackID     *string `json:"track_id"`
	AlbumID     *string `json:"album_id"`
	ArtistID    *string `json:"artist_id"`
	Title       string  `json:"title"`
	ArtistName  string  `json:"artist_name"`
	AlbumTitle  string  `json:"album_title"`
	DurationSec int32   `json:"duration_sec"`
	StreamURL   *string `json:"stream_url"`
	AddedAt     string  `json:"added_at"`
	// Unavailable marks an entry whose file is missing from disk (#2527).
	// The track is still a real, known track — its history and likes are
	// intact and the file may come back — so the entry keeps its place and
	// its text; clients render it greyed out and skip over it on playback.
	Unavailable bool `json:"unavailable"`
}

// playlistDetailView extends playlistRowView with the ordered track list.
// Returned by Get / Append / Remove / Reorder so the SPA always has a
// fresh authoritative state after a mutation.
type playlistDetailView struct {
	playlistRowView
	Tracks []playlistTrackView `json:"tracks"`
}

// listPlaylistsResponse splits owned vs. public so the SPA can render
// "your playlists" and "browse public" without re-filtering. Both
// arrays are guaranteed non-nil for stable JSON output.
type listPlaylistsResponse struct {
	Owned  []playlistRowView `json:"owned"`
	Public []playlistRowView `json:"public"`
}

// handleListPlaylists implements GET /api/playlists. The service returns
// every playlist visible to the caller (own + others' public); we split
// the result here so the wire shape carries the distinction the UI cares
// about without a second DB query.
//
// An optional ?kind= query parameter filters the Owned bucket:
//   - "user" (default): only user-created playlists
//   - "system": only system-generated mixes
//   - "all": no filter
//
// The Public bucket (other users' public playlists) is always unfiltered
// since system mixes are always private.
func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}

	kind := strings.TrimSpace(r.URL.Query().Get("kind"))
	if kind == "" {
		kind = "user"
	}
	switch kind {
	case "user", "system", "all":
		// valid
	default:
		writeErr(w, apierror.BadRequest("bad_kind", "kind must be one of: user, system, all"))
		return
	}

	// Lazy fallback: when the caller wants system mixes and they're stale,
	// kick a background build but serve current state.
	if kind == "system" || kind == "all" {
		h.maybeKickBackgroundBuild(r.Context(), caller.ID)
	}

	rows, err := h.playlists.List(r.Context(), caller.ID)
	if err != nil {
		h.logger.Error("api: list playlists failed", "err", err)
		writeErr(w, apierror.InternalMsg("list failed", err))
		return
	}
	resp := listPlaylistsResponse{Owned: []playlistRowView{}, Public: []playlistRowView{}}
	for i := range rows {
		v := playlistRowToView(&rows[i])
		if uuidEqual(rows[i].UserID, caller.ID) {
			// Filter own playlists by the kind selector.
			if kind != "all" && rows[i].Kind != kind {
				continue
			}
			resp.Owned = append(resp.Owned, v)
		} else {
			// Public-from-others: kind filter doesn't apply (system mixes
			// are always private; this slot is for public-share feature).
			resp.Public = append(resp.Public, v)
		}
	}
	writeJSON(w, http.StatusOK, resp)
}

// maybeKickBackgroundBuild fires a background BuildSystemPlaylists when the
// run is stale (>24h) AND not currently in_flight. Best-effort: any error
// fetching the run row is silently swallowed. A real cron tick will catch
// up the user within 24h regardless.
func (h *handlers) maybeKickBackgroundBuild(ctx context.Context, userID pgtype.UUID) {
	run, err := dbq.New(h.pool).GetSystemPlaylistRun(ctx, userID)
	var stale bool
	switch {
	case errors.Is(err, pgx.ErrNoRows):
		stale = true // no run yet — first request — definitely stale
	case err != nil:
		h.logger.Warn("lazy system build: get run row failed", "err", err)
		return
	case !run.LastRunAt.Valid:
		stale = true
	case run.LastRunAt.Time.Before(time.Now().Add(-24 * time.Hour)):
		stale = true
	}
	if !stale || run.InFlight {
		return
	}
	go func() {
		bgCtx := context.Background()
		if err := playlists.BuildSystemPlaylists(bgCtx, h.pool, h.logger, userID, time.Now(), h.dataDir); err != nil {
			h.logger.Warn("lazy system playlist build failed",
				"user_id", "redacted", "err", err)
		}
	}()
}

type createPlaylistBody struct {
	Name        string `json:"name"`
	Description string `json:"description"`
	IsPublic    bool   `json:"is_public"`
}

// handleCreatePlaylist implements POST /api/playlists. Empty Name is
// rejected by the service (ErrInvalidInput → 400 bad_request).
func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	var body createPlaylistBody
	if !decodeBody(w, r, &body) {
		return
	}
	row, err := h.playlists.Create(r.Context(), caller.ID, body.Name, body.Description, body.IsPublic)
	if err != nil {
		h.writePlaylistErr(w, err, "create")
		return
	}
	h.publishPlaylistEvent("playlist.created", caller.ID, row.ID)
	writeJSON(w, http.StatusOK, playlistRowToView(row))
}

// handleGetPlaylist implements GET /api/playlists/{id}. Visibility is
// owner-or-public; the service returns ErrForbidden when a non-owner
// asks for a private playlist.
func (h *handlers) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	playlistID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
	if err != nil {
		h.writePlaylistErr(w, err, "get")
		return
	}
	writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}

// updatePlaylistBody uses pointers so omitted fields stay untouched
// (PATCH semantics). The service rejects an explicitly-empty Name.
type updatePlaylistBody struct {
	Name        *string `json:"name,omitempty"`
	Description *string `json:"description,omitempty"`
	IsPublic    *bool   `json:"is_public,omitempty"`
}

// handleUpdatePlaylist implements PATCH /api/playlists/{id}. Owner only;
// non-owners get 403 not_authorized.
func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	playlistID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	var body updatePlaylistBody
	if !decodeBody(w, r, &body) {
		return
	}
	row, err := h.playlists.Update(r.Context(), caller.ID, playlistID, playlists.UpdateInput{
		Name:        body.Name,
		Description: body.Description,
		IsPublic:    body.IsPublic,
	})
	if err != nil {
		h.writePlaylistErr(w, err, "update")
		return
	}
	h.publishPlaylistEvent("playlist.updated", caller.ID, playlistID)
	writeJSON(w, http.StatusOK, playlistRowToView(row))
}

// handleDeletePlaylist implements DELETE /api/playlists/{id}. Owner only;
// returns 204 on success.
func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	playlistID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	if err := h.playlists.Delete(r.Context(), caller.ID, playlistID); err != nil {
		h.writePlaylistErr(w, err, "delete")
		return
	}
	h.publishPlaylistEvent("playlist.deleted", caller.ID, playlistID)
	w.WriteHeader(http.StatusNoContent)
}

type appendTracksBody struct {
	TrackIDs []string `json:"track_ids"`
}

// handleAppendTracks implements POST /api/playlists/{id}/tracks. Owner
// only. Returns the playlist detail (with the new track rows) so the
// SPA never has to re-GET after a mutation.
func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	playlistID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	var body appendTracksBody
	if !decodeBody(w, r, &body) {
		return
	}
	trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs))
	for _, s := range body.TrackIDs {
		u, ok := parseUUID(s)
		if !ok {
			writeErr(w, apierror.BadRequest("bad_request", fmt.Sprintf("invalid track id %q", s)))
			return
		}
		trackIDs = append(trackIDs, u)
	}
	if err := h.playlists.AppendTracks(r.Context(), caller.ID, playlistID, trackIDs); err != nil {
		h.writePlaylistErr(w, err, "append tracks")
		return
	}
	h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
	detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
	if err != nil {
		h.writePlaylistErr(w, err, "get-after-append")
		return
	}
	writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}

// handleRemovePlaylistTrack implements DELETE /api/playlists/{id}/tracks/{position}.
// Owner only. Returns the post-mutation detail like AppendTracks.
func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	playlistID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	posStr := chi.URLParam(r, "position")
	pos64, err := strconv.ParseInt(posStr, 10, 32)
	if err != nil || pos64 < 0 {
		writeErr(w, apierror.BadRequest("bad_request", "invalid position"))
		return
	}
	if err := h.playlists.RemoveTrack(r.Context(), caller.ID, playlistID, int32(pos64)); err != nil {
		h.writePlaylistErr(w, err, "remove track")
		return
	}
	h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
	detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
	if err != nil {
		h.writePlaylistErr(w, err, "get-after-remove")
		return
	}
	writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}

type reorderTracksBody struct {
	OrderedPositions []int32 `json:"ordered_positions"`
}

// handleReorderPlaylist implements PUT /api/playlists/{id}/tracks. Owner
// only. The body carries a permutation of every existing position; the
// service validates length and uniqueness.
func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	playlistID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	var body reorderTracksBody
	if !decodeBody(w, r, &body) {
		return
	}
	if err := h.playlists.Reorder(r.Context(), caller.ID, playlistID, body.OrderedPositions); err != nil {
		h.writePlaylistErr(w, err, "reorder")
		return
	}
	h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
	detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
	if err != nil {
		h.writePlaylistErr(w, err, "get-after-reorder")
		return
	}
	writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}

// handleGetPlaylistCover serves the cached collage from disk. The
// playlist visibility check (owner-or-public) runs in the service Get
// call before we look at cover_path, so we don't leak the existence of
// private playlists to non-owners.
func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request) {
	caller, ok := requireUser(w, r)
	if !ok {
		return
	}
	playlistID, ok := requireURLUUID(w, r, "id")
	if !ok {
		return
	}
	detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
	if err != nil {
		h.writePlaylistErr(w, err, "get cover")
		return
	}
	if detail.CoverPath == nil || *detail.CoverPath == "" {
		writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "no cover"})
		return
	}
	full := filepath.Join(h.dataDir, *detail.CoverPath)
	// Playlist collages recompute when the playlist's tracks change
	// (system playlists re-rendered on rebuild, user playlists when
	// modified). 5 minutes is short enough for normal edits to feel
	// fresh, long enough to skip repeat fetches during a session.
	w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
	http.ServeFile(w, r, full)
}

// writePlaylistErr maps the typed errors out of the playlists service to
// the project's standard error envelope. Unknown errors are logged at
// the handler boundary and surfaced as 500 server_error so callers don't
// see service-internal wrap text.
func (h *handlers) writePlaylistErr(w http.ResponseWriter, err error, op string) {
	switch {
	case errors.Is(err, playlists.ErrNotFound):
		writeErr(w, apierror.NotFound("playlist"))
	case errors.Is(err, playlists.ErrForbidden):
		writeErr(w, apierror.Forbidden("not_authorized", "you don't own this playlist"))
	case errors.Is(err, playlists.ErrSystemPlaylistReadonly):
		writeErr(w, apierror.Forbidden("system_playlist_readonly", "system-generated playlists cannot be edited"))
	case errors.Is(err, playlists.ErrInvalidInput):
		writeErr(w, apierror.BadRequest("bad_request", err.Error()))
	case errors.Is(err, playlists.ErrTrackNotFound):
		writeErr(w, apierror.BadRequest("bad_request", "one of the supplied track ids does not exist"))
	default:
		h.logger.Error("api: playlist op failed", "op", op, "err", err)
		writeErr(w, apierror.InternalMsg(op+" failed", err))
	}
}

// playlistRowToView projects a service-layer PlaylistRow onto the wire
// shape. CoverURL is derived from the presence of cover_path — the
// /cover endpoint serves the file from disk.
func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
	v := playlistRowView{
		ID:            uuidToString(r.ID),
		UserID:        uuidToString(r.UserID),
		OwnerUsername: r.OwnerUsername,
		Name:          r.Name,
		Description:   r.Description,
		IsPublic:      r.IsPublic,
		Kind:          r.Kind,
		SystemVariant: r.SystemVariant,
		Refreshable:   r.SystemVariant != nil && playlists.RefreshableSystemKind(*r.SystemVariant),
		TrackCount:    r.TrackCount,
		DurationSec:   r.DurationSec,
		CreatedAt:     formatTimestamp(r.CreatedAt),
		UpdatedAt:     formatTimestamp(r.UpdatedAt),
	}
	if r.SeedArtistID.Valid {
		s := uuidToString(r.SeedArtistID)
		v.SeedArtistID = &s
	}
	if r.CoverPath != nil && *r.CoverPath != "" {
		v.CoverURL = "/api/playlists/" + v.ID + "/cover"
	}
	return v
}

// playlistDetailToView extends playlistRowToView with the ordered track
// list. Empty playlists get a non-nil empty slice so the JSON encoder
// renders `"tracks": []` rather than `null`.
func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
	out := playlistDetailView{
		playlistRowView: playlistRowToView(&d.PlaylistRow),
		Tracks:          make([]playlistTrackView, 0, len(d.Tracks)),
	}
	for _, t := range d.Tracks {
		v := playlistTrackView{
			Position:    t.Position,
			Title:       t.Title,
			ArtistName:  t.ArtistName,
			AlbumTitle:  t.AlbumTitle,
			DurationSec: t.DurationSec,
			AddedAt:     formatTimestamp(t.AddedAt),
			Unavailable: t.Unavailable,
		}
		if t.TrackID != nil {
			s := uuidToString(*t.TrackID)
			v.TrackID = &s
			// No stream URL for a missing file. The wire refuses to offer
			// a URL that cannot serve rather than trusting every client to
			// honour Unavailable — and `"stream_url": null` is a shape the
			// clients already handle (the track-removed case), so an older
			// build degrades to "present but not playable" on its own.
			if !t.Unavailable {
				url := streamURL(*t.TrackID)
				v.StreamURL = &url
			}
		}
		if t.AlbumID != nil {
			s := uuidToString(*t.AlbumID)
			v.AlbumID = &s
		}
		if t.ArtistID != nil {
			s := uuidToString(*t.ArtistID)
			v.ArtistID = &s
		}
		out.Tracks = append(out.Tracks, v)
	}
	return out
}

// uuidEqual compares two pgtype.UUID values for identity. Two
// invalid/zero UUIDs never compare equal — the caller's user id is
// always a real value, and we don't want a malformed playlist row to
// accidentally land in the "owned" bucket.
func uuidEqual(a, b pgtype.UUID) bool {
	if !a.Valid || !b.Valid {
		return false
	}
	return a.Bytes == b.Bytes
}
