// Package playlists implements the user-facing playlist CRUD service.
//
// Visibility model (from the M7 #352 design spec):
//   - Playlists are private by default. Only the owner sees private rows
//     in List, Get, and is allowed to mutate them via Update / Delete.
//   - is_public = true makes a playlist readable by any authenticated
//     user; mutations are still owner-only.
//
// Track-list mutations (AppendTracks / RemoveTrack / Reorder) bump
// the denormalized rollups (track_count, duration_sec) on the playlist
// row and trigger cover-art regeneration via GenerateCollage. The
// collage generator itself lives alongside in collage.go.
package playlists

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"

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

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

// Typed errors. The API layer maps each to its wire status code; tests
// use errors.Is to assert the right path was taken.
//
// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
// against the shared sentinel; existing playlists.ErrNotFound callsites
// still resolve to the same pointer.
var (
	ErrNotFound               = apierror.ErrNotFound
	ErrForbidden              = errors.New("playlists: forbidden")
	ErrInvalidInput           = errors.New("playlists: invalid input")
	ErrTrackNotFound          = errors.New("playlists: track not found")
	ErrSystemPlaylistReadonly = errors.New("playlists: system playlist is read-only")
)

// Service wires the sqlc queries + on-disk cover-art directory together.
// dataDir is the root for cached cover-art collages (cover_path on the
// playlist row is stored relative to this directory so the install can
// be relocated without rewriting rows).
type Service struct {
	pool    *pgxpool.Pool
	logger  *slog.Logger
	dataDir string
}

// NewService constructs a Service. A nil logger gets slog.Default().
func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service {
	if logger == nil {
		logger = slog.Default()
	}
	return &Service{pool: pool, logger: logger, dataDir: dataDir}
}

// PlaylistRow is the wire shape returned by Create / Update / List /
// the embedded portion of Get. owner_username is denormalized in via
// the JOIN on users so callers don't have to resolve it themselves.
type PlaylistRow struct {
	ID            pgtype.UUID
	UserID        pgtype.UUID
	OwnerUsername string
	Name          string
	Description   string
	IsPublic      bool
	Kind          string      // 'user' | 'system'
	SystemVariant *string     // 'for_you' | 'songs_like_artist' | nil
	SeedArtistID  pgtype.UUID // Valid=true only for 'songs_like_artist'
	CoverPath     *string
	TrackCount    int32
	DurationSec   int32
	CreatedAt     pgtype.Timestamptz
	UpdatedAt     pgtype.Timestamptz
}

// PlaylistDetail extends PlaylistRow with the ordered track list.
// Returned by Get only; the List endpoint stays cheap and returns
// header-only PlaylistRows.
type PlaylistDetail struct {
	PlaylistRow
	Tracks []PlaylistTrack
}

// PlaylistTrack is one row of a playlist's ordered track list.
//
// Title / ArtistName / AlbumTitle / DurationSec are the snapshot the
// playlist row was created with — they remain authoritative even after
// the upstream tracks/albums/artists rows change or disappear.
//
// TrackID is nil when the upstream tracks row has been deleted from
// the library (ON DELETE SET NULL). AlbumID / ArtistID come from the
// LEFT JOIN through the live track and follow the same nil-on-removed
// semantics — once the track row is gone, neither the album nor the
// artist can be resolved either, so they collapse to nil together.
type PlaylistTrack struct {
	Position    int32
	TrackID     *pgtype.UUID
	AlbumID     *pgtype.UUID
	ArtistID    *pgtype.UUID
	Title       string
	ArtistName  string
	AlbumTitle  string
	DurationSec int32
	AddedAt     pgtype.Timestamptz
	// Unavailable reports that the track still exists in the library but
	// its file is currently missing from disk (tracks.missing_since is
	// set, #2527). The entry keeps its place in the playlist — the user
	// put it there — but nothing should try to play it.
	Unavailable bool
}

// Create makes a new playlist owned by userID.
//
// The post-create GetPlaylist call exists to pick up owner_username
// from the users JOIN — CreatePlaylist returns only playlist columns,
// not the joined username, so we'd otherwise have to resolve it twice
// in the API layer.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, name, description string, isPublic bool) (*PlaylistRow, error) {
	if name == "" {
		return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
	}
	q := dbq.New(s.pool)
	row, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{
		UserID:      userID,
		Name:        name,
		Description: description,
		IsPublic:    isPublic,
	})
	if err != nil {
		return nil, fmt.Errorf("create playlist: %w", err)
	}
	if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
		syncpkg.FormatUUID(row.ID), syncpkg.OpUpsert); err != nil {
		s.logger.Warn("playlists: LogChange create failed", "playlist_id", row.ID, "err", err)
	}
	full, err := q.GetPlaylist(ctx, row.ID)
	if err != nil {
		return nil, fmt.Errorf("get-after-create: %w", err)
	}
	return playlistFromGetRow(full), nil
}

// Get returns the playlist identified by playlistID along with its
// ordered track list. Visibility: owner can always see; non-owners
// only see public playlists. Returns ErrNotFound for missing rows
// and ErrForbidden when a non-owner asks for a private one.
func (s *Service) Get(ctx context.Context, callerID, playlistID pgtype.UUID) (*PlaylistDetail, error) {
	q := dbq.New(s.pool)
	row, err := q.GetPlaylist(ctx, playlistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("get playlist: %w", err)
	}
	if !row.IsPublic && !pgtypeUUIDEqual(row.UserID, callerID) {
		return nil, ErrForbidden
	}

	trackRows, err := q.ListPlaylistTracks(ctx, playlistID)
	if err != nil {
		return nil, fmt.Errorf("list tracks: %w", err)
	}
	tracks := make([]PlaylistTrack, 0, len(trackRows))
	for _, t := range trackRows {
		pt := PlaylistTrack{
			Position:    t.Position,
			Title:       t.Title,
			ArtistName:  t.ArtistName,
			AlbumTitle:  t.AlbumTitle,
			DurationSec: t.DurationSec,
			AddedAt:     t.AddedAt,
			Unavailable: t.MissingSince.Valid,
		}
		// pt.track_id (snapshot FK, ON DELETE SET NULL) and the joined
		// live_track_id should agree on validity in normal operation —
		// prefer the snapshot's track_id because it survives even when
		// the live row's other join columns happen to be NULL for
		// unrelated reasons.
		if t.TrackID.Valid {
			id := t.TrackID
			pt.TrackID = &id
		}
		if t.AlbumID.Valid {
			id := t.AlbumID
			pt.AlbumID = &id
		}
		if t.ArtistID.Valid {
			id := t.ArtistID
			pt.ArtistID = &id
		}
		tracks = append(tracks, pt)
	}

	return &PlaylistDetail{
		PlaylistRow: *playlistFromGetRow(row),
		Tracks:      tracks,
	}, nil
}

// List returns all playlists visible to callerID: every playlist owned
// by the caller (any visibility) plus every other user's public
// playlists, ordered newest-first by updated_at.
func (s *Service) List(ctx context.Context, callerID pgtype.UUID) ([]PlaylistRow, error) {
	q := dbq.New(s.pool)
	rows, err := q.ListPlaylistsForUser(ctx, callerID)
	if err != nil {
		return nil, fmt.Errorf("list playlists: %w", err)
	}
	out := make([]PlaylistRow, 0, len(rows))
	for _, r := range rows {
		out = append(out, *playlistFromListRow(r))
	}
	return out, nil
}

// UpdateInput captures optional fields. Each pointer being nil means
// "don't touch" — non-nil triggers a write of that column. The sqlc
// UpdatePlaylist query uses CASE-WHEN flags so we can keep PATCH
// semantics with one query rather than N variants.
type UpdateInput struct {
	Name        *string
	Description *string
	IsPublic    *bool
}

// Update applies a partial update to playlistID. Only the owner may
// mutate — non-owners (including admins, currently) get ErrForbidden.
// Empty Name (when explicitly set) is rejected with ErrInvalidInput
// rather than silently no-op'd, since the user clearly intended to
// change something.
func (s *Service) Update(ctx context.Context, callerID, playlistID pgtype.UUID, in UpdateInput) (*PlaylistRow, error) {
	q := dbq.New(s.pool)
	existing, err := q.GetPlaylist(ctx, playlistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("get-before-update: %w", err)
	}
	if !pgtypeUUIDEqual(existing.UserID, callerID) {
		return nil, ErrForbidden
	}
	if existing.Kind == "system" {
		return nil, ErrSystemPlaylistReadonly
	}

	params := dbq.UpdatePlaylistParams{
		ID:                playlistID,
		UpdateName:        in.Name != nil,
		UpdateDescription: in.Description != nil,
		UpdateIsPublic:    in.IsPublic != nil,
	}
	if in.Name != nil {
		if *in.Name == "" {
			return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
		}
		params.Name = *in.Name
	}
	if in.Description != nil {
		params.Description = *in.Description
	}
	if in.IsPublic != nil {
		params.IsPublic = *in.IsPublic
	}

	updated, err := q.UpdatePlaylist(ctx, params)
	if err != nil {
		return nil, fmt.Errorf("update playlist: %w", err)
	}
	if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
		syncpkg.FormatUUID(updated.ID), syncpkg.OpUpsert); err != nil {
		s.logger.Warn("playlists: LogChange update failed", "playlist_id", updated.ID, "err", err)
	}
	full, err := q.GetPlaylist(ctx, updated.ID)
	if err != nil {
		return nil, fmt.Errorf("get-after-update: %w", err)
	}
	return playlistFromGetRow(full), nil
}

// Delete removes playlistID. Owner-only, like Update. After the row is
// gone, a best-effort attempt is made to remove the cached cover-art
// collage from disk; failures other than ENOENT are logged but do not
// fail the call (the row is already gone, returning an error would
// confuse callers about whether the delete took effect).
func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID) error {
	q := dbq.New(s.pool)
	existing, err := q.GetPlaylist(ctx, playlistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ErrNotFound
		}
		return fmt.Errorf("get-before-delete: %w", err)
	}
	if !pgtypeUUIDEqual(existing.UserID, callerID) {
		return ErrForbidden
	}
	if existing.Kind == "system" {
		return ErrSystemPlaylistReadonly
	}

	deleted, err := q.DeletePlaylist(ctx, playlistID)
	if err != nil {
		return fmt.Errorf("delete playlist: %w", err)
	}
	if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
		syncpkg.FormatUUID(deleted.ID), syncpkg.OpDelete); err != nil {
		s.logger.Warn("playlists: LogChange delete failed", "playlist_id", deleted.ID, "err", err)
	}
	if deleted.CoverPath != nil && *deleted.CoverPath != "" {
		full := filepath.Join(s.dataDir, *deleted.CoverPath)
		if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
			s.logger.Warn("playlist delete: cover file remove failed",
				"path", full, "playlist_id", playlistID, "err", rerr)
		}
	}
	return nil
}

// AssertEditable returns nil if the playlist exists, the caller owns it,
// AND the playlist is user-created (kind='user'). Returns:
//   - ErrNotFound — playlist doesn't exist
//   - ErrForbidden — caller is not the owner
//   - ErrSystemPlaylistReadonly — playlist exists but kind='system'
//
// Edit handlers call this first; on error, the API layer maps the typed
// error to the appropriate status code.
func (s *Service) AssertEditable(ctx context.Context, callerID, playlistID pgtype.UUID) error {
	q := dbq.New(s.pool)
	p, err := q.GetPlaylist(ctx, playlistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ErrNotFound
		}
		return fmt.Errorf("playlists: load for editability check: %w", err)
	}
	if !pgtypeUUIDEqual(p.UserID, callerID) {
		return ErrForbidden
	}
	if p.Kind == "system" {
		return ErrSystemPlaylistReadonly
	}
	return nil
}

// AppendTracks adds the given track ids at the end of the playlist,
// preserving order. Snapshot fields (title, artist, album, duration_sec)
// are populated from the tracks/albums/artists join at insert time.
// Triggers cover regeneration after.
func (s *Service) AppendTracks(ctx context.Context, callerID, playlistID pgtype.UUID, trackIDs []pgtype.UUID) error {
	if len(trackIDs) == 0 {
		return fmt.Errorf("%w: at least one track id required", ErrInvalidInput)
	}
	q := dbq.New(s.pool)
	pl, err := q.GetPlaylist(ctx, playlistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ErrNotFound
		}
		return fmt.Errorf("get playlist: %w", err)
	}
	if !pgtypeUUIDEqual(pl.UserID, callerID) {
		return ErrForbidden
	}
	if pl.Kind == "system" {
		return ErrSystemPlaylistReadonly
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()
	tq := dbq.New(tx)

	for _, tid := range trackIDs {
		_, ierr := tq.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
			PlaylistID: playlistID,
			TrackID:    tid,
		})
		if ierr != nil {
			// AppendPlaylistTrack uses INSERT ... SELECT FROM tracks WHERE id = X.
			// If X doesn't exist, no row inserts and the :one query errors with
			// pgx.ErrNoRows.
			if errors.Is(ierr, pgx.ErrNoRows) {
				return ErrTrackNotFound
			}
			return fmt.Errorf("append track: %w", ierr)
		}
		// Tx-bound: change row commits with the playlist_track row.
		if lerr := syncpkg.LogChange(ctx, tx, syncpkg.EntityPlaylistTrack,
			syncpkg.EncodePlaylistTrackID(syncpkg.FormatUUID(playlistID), syncpkg.FormatUUID(tid)),
			syncpkg.OpUpsert); lerr != nil {
			return fmt.Errorf("log change: %w", lerr)
		}
	}
	if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
		return fmt.Errorf("update rollups: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("commit: %w", err)
	}

	// Regenerate cover (synchronous). Failure is logged but not returned —
	// the playlist mutation already committed.
	if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
		s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
	}
	return nil
}

// RemoveTrack deletes the row at `position` and renumbers subsequent
// rows to close the gap. Triggers cover regeneration after.
func (s *Service) RemoveTrack(ctx context.Context, callerID, playlistID pgtype.UUID, position int32) error {
	q := dbq.New(s.pool)
	pl, err := q.GetPlaylist(ctx, playlistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ErrNotFound
		}
		return fmt.Errorf("get playlist: %w", err)
	}
	if !pgtypeUUIDEqual(pl.UserID, callerID) {
		return ErrForbidden
	}
	if pl.Kind == "system" {
		return ErrSystemPlaylistReadonly
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()
	tq := dbq.New(tx)

	// Capture the track_id before we delete the row — we need it for the
	// LogChange composite key after the delete commits.
	var deletedTrackID pgtype.UUID
	if err := tx.QueryRow(ctx,
		`SELECT track_id FROM playlist_tracks WHERE playlist_id = $1 AND position = $2`,
		playlistID, position,
	).Scan(&deletedTrackID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ErrNotFound
		}
		return fmt.Errorf("lookup track at position: %w", err)
	}

	if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{
		PlaylistID: playlistID,
		Position:   position,
	}); err != nil {
		return fmt.Errorf("delete track row: %w", err)
	}
	if lerr := syncpkg.LogChange(ctx, tx, syncpkg.EntityPlaylistTrack,
		syncpkg.EncodePlaylistTrackID(syncpkg.FormatUUID(playlistID), syncpkg.FormatUUID(deletedTrackID)),
		syncpkg.OpDelete); lerr != nil {
		return fmt.Errorf("log change: %w", lerr)
	}
	if err := tq.RenumberPlaylistTracksAfter(ctx, dbq.RenumberPlaylistTracksAfterParams{
		PlaylistID: playlistID,
		Position:   position,
	}); err != nil {
		return fmt.Errorf("renumber: %w", err)
	}
	if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
		return fmt.Errorf("update rollups: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("commit: %w", err)
	}

	if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
		s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
	}
	return nil
}

// Reorder atomically rewrites all positions to the supplied permutation.
// orderedPositions is the new positional order: if the playlist has 4
// tracks at positions [0, 1, 2, 3] and the caller wants the order
// (old[3], old[0], old[1], old[2]), they send [3, 0, 1, 2].
func (s *Service) Reorder(ctx context.Context, callerID, playlistID pgtype.UUID, orderedPositions []int32) error {
	q := dbq.New(s.pool)
	pl, err := q.GetPlaylist(ctx, playlistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ErrNotFound
		}
		return fmt.Errorf("get playlist: %w", err)
	}
	if !pgtypeUUIDEqual(pl.UserID, callerID) {
		return ErrForbidden
	}
	if pl.Kind == "system" {
		return ErrSystemPlaylistReadonly
	}

	if int32(len(orderedPositions)) != pl.TrackCount {
		return fmt.Errorf("%w: expected %d positions, got %d", ErrInvalidInput, pl.TrackCount, len(orderedPositions))
	}
	seen := make(map[int32]struct{}, len(orderedPositions))
	for _, p := range orderedPositions {
		if p < 0 || p >= pl.TrackCount {
			return fmt.Errorf("%w: position %d out of range", ErrInvalidInput, p)
		}
		if _, dup := seen[p]; dup {
			return fmt.Errorf("%w: position %d duplicated", ErrInvalidInput, p)
		}
		seen[p] = struct{}{}
	}

	// Rewrite strategy: bump every row's position by +10000 first (out of
	// range of valid positions and the unique PK) then write the new
	// positions one by one. Single transaction.
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()

	if _, err := tx.Exec(ctx,
		`UPDATE playlist_tracks SET position = position + 10000 WHERE playlist_id = $1`,
		playlistID); err != nil {
		return fmt.Errorf("offset positions: %w", err)
	}
	for newPos, oldPos := range orderedPositions {
		if _, err := tx.Exec(ctx,
			`UPDATE playlist_tracks SET position = $1 WHERE playlist_id = $2 AND position = $3`,
			int32(newPos), playlistID, oldPos+10000); err != nil {
			return fmt.Errorf("write new position: %w", err)
		}
	}
	if err := dbq.New(tx).UpdatePlaylistRollups(ctx, playlistID); err != nil {
		return fmt.Errorf("update rollups: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("commit: %w", err)
	}

	// Always regenerate the cover. Reasoning about which moves matter is
	// more error-prone than just doing the work; the collage is cheap.
	if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
		s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
	}
	return nil
}

func playlistFromGetRow(r dbq.GetPlaylistRow) *PlaylistRow {
	return &PlaylistRow{
		ID:            r.ID,
		UserID:        r.UserID,
		OwnerUsername: r.OwnerUsername,
		Name:          r.Name,
		Description:   r.Description,
		IsPublic:      r.IsPublic,
		Kind:          r.Kind,
		SystemVariant: r.SystemVariant,
		SeedArtistID:  r.SeedArtistID,
		CoverPath:     r.CoverPath,
		TrackCount:    r.TrackCount,
		DurationSec:   r.DurationSec,
		CreatedAt:     r.CreatedAt,
		UpdatedAt:     r.UpdatedAt,
	}
}

func playlistFromListRow(r dbq.ListPlaylistsForUserRow) *PlaylistRow {
	return &PlaylistRow{
		ID:            r.ID,
		UserID:        r.UserID,
		OwnerUsername: r.OwnerUsername,
		Name:          r.Name,
		Description:   r.Description,
		IsPublic:      r.IsPublic,
		Kind:          r.Kind,
		SystemVariant: r.SystemVariant,
		SeedArtistID:  r.SeedArtistID,
		CoverPath:     r.CoverPath,
		TrackCount:    r.TrackCount,
		DurationSec:   r.DurationSec,
		CreatedAt:     r.CreatedAt,
		UpdatedAt:     r.UpdatedAt,
	}
}

// pgtypeUUIDEqual treats two zero/invalid UUIDs as not-equal. The
// service uses this for owner-vs-caller checks where an invalid UUID
// can never legitimately match anything.
func pgtypeUUIDEqual(a, b pgtype.UUID) bool {
	if !a.Valid || !b.Valid {
		return false
	}
	return a.Bytes == b.Bytes
}
