// Package playlist provides playlist management functionality.
package playlist

import (
	"cmp"
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/wailsapp/wails/v3/pkg/application"

	"yellowjacket/backend/coverart"
	"yellowjacket/backend/database"
	"yellowjacket/backend/database/sql/sqlcgen"
	"yellowjacket/backend/events"
	"yellowjacket/backend/library"
	"yellowjacket/backend/smartplaylist"
	"yellowjacket/backend/system"
)

var (
	errEmptyName           = errors.New("playlist name cannot be empty")
	errEmptyFilePath       = errors.New("file path cannot be empty")
	errNoFilePaths         = errors.New("no file paths provided")
	errUnsupportedFileType = errors.New("unsupported file type")
)

// playlistsDirName is the subdirectory within the user data
// directory where M3U8 playlist files are stored.
const playlistsDirName = "playlists"

// LibraryDirProvider is a narrow interface for obtaining the
// configured library directory path.
type LibraryDirProvider interface {
	GetLibraryDirectory() string
}

// Summary is a lightweight representation of a playlist for the
// picker UI.
type Summary struct {
	ID        int64  `json:"ID"`
	Name      string `json:"Name"`
	CreatedAt string `json:"CreatedAt"`
	UpdatedAt string `json:"UpdatedAt"`
	IsSmart   bool   `json:"IsSmart"`
}

// Track represents a track within a playlist, including its
// metadata.
type Track struct {
	ID               int64  `json:"ID"`
	Position         int64  `json:"Position"`
	FilePath         string `json:"FilePath"`
	Title            string `json:"Title"`
	Artist           string `json:"Artist"`
	Album            string `json:"Album"`
	CoverArtPath     string `json:"CoverArtPath"`
	CoverArtSmall    string `json:"CoverArtSmall"`
	CoverArtMedium   string `json:"CoverArtMedium"`
	CoverArtLarge    string `json:"CoverArtLarge"`
	Duration         string `json:"Duration"`
	Phantom          bool   `json:"Phantom"`
	ArtistMBID       string `json:"ArtistMBID"`
	ReleaseGroupMBID string `json:"ReleaseGroupMBID"`
	RecordingMBID    string `json:"RecordingMBID"`
}

// WithTracks contains a playlist summary and all its tracks.
type WithTracks struct {
	Summary Summary `json:"Summary"`
	Tracks  []Track `json:"Tracks"`
}

// CandidateTrack represents a potential library match for a
// phantom track.
type CandidateTrack struct {
	FilePath string  `json:"FilePath"`
	Title    string  `json:"Title"`
	Artist   string  `json:"Artist"`
	Album    string  `json:"Album"`
	Duration string  `json:"Duration"`
	Score    float64 `json:"Score"`
}

// PhantomMatch represents a high-confidence pairing of a phantom
// track to a library track.
type PhantomMatch struct {
	PhantomPath  string         `json:"PhantomPath"`
	PhantomTitle string         `json:"PhantomTitle"`
	Candidate    CandidateTrack `json:"Candidate"`
}

// PhantomSearchResult contains auto-matched pairs and remaining
// unmatched phantom paths for a batch search operation.
type PhantomSearchResult struct {
	AutoMatched []PhantomMatch `json:"AutoMatched"`
	Unmatched   []string       `json:"Unmatched"`
}

// DuplicateTrackInfo holds metadata for a track that already
// exists in a playlist.
type DuplicateTrackInfo struct {
	FilePath string `json:"FilePath"`
	Title    string `json:"Title"`
	Artist   string `json:"Artist"`
	Album    string `json:"Album"`
	Duration string `json:"Duration"`
}

// DuplicateCheckResult contains the outcome of checking for
// duplicate tracks in a playlist.
type DuplicateCheckResult struct {
	Duplicates []DuplicateTrackInfo `json:"Duplicates"`
	Unique     []string             `json:"Unique"`
}

// Service manages playlist operations.
type Service struct {
	// mu protects ctx and favoritesConf from concurrent access
	// during initialization.
	mu            sync.Mutex
	ctx           context.Context
	logger        *slog.Logger
	db            *database.DB
	libraryDir    LibraryDirProvider
	favoritesConf FavoritesConfigProvider

	// dataDirOverride, when non-empty, replaces the OS user data
	// directory as the base for the playlists folder. Set by tests to
	// keep M3U writes out of the real user data directory.
	dataDirOverride string
}

// NewService creates a new playlist service.
func NewService(
	logger *slog.Logger,
	db *database.DB,
	libraryDir LibraryDirProvider,
) *Service {
	return &Service{
		logger:     logger.WithGroup("playlist"),
		db:         db,
		libraryDir: libraryDir,
	}
}

// SetFavoritesConfig sets the provider used to read and write
// the default-playlist configuration.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (s *Service) SetFavoritesConfig(
	provider FavoritesConfigProvider,
) {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.favoritesConf = provider
}

// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down.  It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (s *Service) ServiceStartup(
	ctx context.Context,
	_ application.ServiceOptions,
) error {
	s.mu.Lock()
	s.ctx = ctx
	s.mu.Unlock()

	s.migrateExistingPlaylists()

	return nil
}

// GetAllPlaylists returns all playlists ordered by most recently
// updated.
func (s *Service) GetAllPlaylists() ([]Summary, error) {
	playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
	if err != nil {
		s.logger.Error(
			"Failed to get playlists", "err", err,
		)

		return nil, fmt.Errorf(
			"failed to get playlists: %w", err,
		)
	}

	summaries := make([]Summary, 0, len(playlists))

	for _, p := range playlists {
		summaries = append(summaries, Summary{
			ID:        p.ID,
			Name:      p.Name,
			CreatedAt: p.CreatedAt.Format(time.RFC3339),
			UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
			IsSmart:   p.IsSmart != 0,
		})
	}

	return summaries, nil
}

// GetAllPlaylistsWithTracks returns all playlists with their
// tracks in a single call, merging phantom tracks from M3U8 files.
func (s *Service) GetAllPlaylistsWithTracks() (
	[]WithTracks,
	error,
) {
	playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
	if err != nil {
		s.logger.Error(
			"Failed to get playlists", "err", err,
		)

		return nil, fmt.Errorf(
			"failed to get playlists: %w", err,
		)
	}

	rows, err := s.db.ReadQueries.GetAllPlaylistTracksWithMetadata(
		s.db.Ctx,
	)
	if err != nil {
		s.logger.Error(
			"Failed to get all playlist tracks",
			"err", err,
		)

		return nil, fmt.Errorf(
			"failed to get all playlist tracks: %w",
			err,
		)
	}

	// Group DB tracks by playlist ID, keyed by absolute file path.
	dbTracksByPlaylist := make(
		map[int64]map[string]Track,
	)

	for _, row := range rows {
		track := trackFromRow(
			row.ID,
			row.Position,
			row.FilePath,
			row.Title,
			row.Artist,
			row.Album,
			row.LengthMilliseconds,
			row.CoverArtPath,
			row.ArtistMbid,
			row.ReleaseGroupMbid,
			row.RecordingMbid,
		)

		if dbTracksByPlaylist[row.PlaylistID] == nil {
			dbTracksByPlaylist[row.PlaylistID] = make(
				map[string]Track,
			)
		}

		dbTracksByPlaylist[row.PlaylistID][row.FilePath] = track
	}

	result := make([]WithTracks, 0, len(playlists))

	for _, p := range playlists {
		tracks := s.mergeTracksForPlaylist(
			p.ID,
			p.Name,
			dbTracksByPlaylist[p.ID],
		)

		result = append(result, WithTracks{
			Summary: Summary{
				ID:        p.ID,
				Name:      p.Name,
				CreatedAt: p.CreatedAt.Format(time.RFC3339),
				UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
				IsSmart:   p.IsSmart != 0,
			},
			Tracks: tracks,
		})
	}

	return result, nil
}

// GetPlaylistTracks returns all tracks in a playlist with full
// metadata, merging phantom tracks from the M3U8 file.
func (s *Service) GetPlaylistTracks(
	playlistID int64,
) ([]Track, error) {
	rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
		s.db.Ctx,
		playlistID,
	)
	if err != nil {
		s.logger.Error(
			"Failed to get playlist tracks",
			"playlistId", playlistID,
			"err", err,
		)

		return nil, fmt.Errorf(
			"failed to get playlist tracks: %w",
			err,
		)
	}

	// Build a map of DB tracks keyed by absolute file path.
	dbTracks := make(map[string]Track, len(rows))

	for _, row := range rows {
		track := trackFromRow(
			row.ID,
			row.Position,
			row.FilePath,
			row.Title,
			row.Artist,
			row.Album,
			row.LengthMilliseconds,
			row.CoverArtPath,
			row.ArtistMbid,
			row.ReleaseGroupMbid,
			row.RecordingMbid,
		)

		dbTracks[row.FilePath] = track
	}

	// Get playlist name for M3U file lookup.
	playlist, err := s.db.Queries.GetPlaylist(
		s.db.Ctx, playlistID,
	)
	if err != nil {
		s.logger.Error(
			"Failed to get playlist",
			"playlistId", playlistID,
			"err", err,
		)

		return nil, fmt.Errorf(
			"failed to get playlist: %w", err,
		)
	}

	return s.mergeTracksForPlaylist(
		playlistID, playlist.Name, dbTracks,
	), nil
}

// mergeTracksForPlaylist merges DB tracks with M3U8 entries,
// producing phantom tracks for unresolved paths.
func (s *Service) mergeTracksForPlaylist(
	playlistID int64,
	_ string,
	dbTracks map[string]Track,
) []Track {
	dir, err := s.playlistsDir()
	if err != nil {
		s.logger.Warn(
			"Could not get playlists dir for merge",
			"err", err,
		)

		return dbTracksToSlice(dbTracks)
	}

	m3uPath, err := findPlaylistFile(dir, playlistID)
	if err != nil || m3uPath == "" {
		return dbTracksToSlice(dbTracks)
	}

	parsed, err := parseM3U8(m3uPath)
	if err != nil {
		s.logger.Warn(
			"Could not parse M3U8 for merge",
			"playlistId", playlistID,
			"path", m3uPath,
			"err", err,
		)

		return dbTracksToSlice(dbTracks)
	}

	libraryRoots := s.getAllLibraryRoots()

	// Build a set of known DB paths for multi-root resolution.
	knownPaths := make(
		map[string]struct{}, len(dbTracks),
	)
	for k := range dbTracks {
		knownPaths[k] = struct{}{}
	}

	tracks := make([]Track, 0, len(parsed.Entries))

	for i, entry := range parsed.Entries {
		absPath := resolveM3UPath(
			entry.RelativePath, libraryRoots, knownPaths,
		)

		if dbTrack, ok := dbTracks[absPath]; ok {
			dbTrack.Position = int64(i)
			tracks = append(tracks, dbTrack)

			continue
		}

		// Phantom track — file not resolved in DB.
		tracks = append(tracks, Track{
			Position: int64(i),
			FilePath: absPath,
			Title:    entry.DisplayTitle,
			Phantom:  true,
		})
	}

	return tracks
}

// dbTracksToSlice converts a map of tracks to an ordered slice.
func dbTracksToSlice(m map[string]Track) []Track {
	if len(m) == 0 {
		return []Track{}
	}

	tracks := make([]Track, 0, len(m))

	for _, t := range m {
		tracks = append(tracks, t)
	}

	return tracks
}

// trackFromRow converts raw query row fields into a Track.
func trackFromRow(
	id, position int64,
	filePath, title, artist, album string,
	lengthMilliseconds int64,
	coverArtPath string,
	artistMBID, releaseGroupMBID, recordingMBID string,
) Track {
	track := Track{
		ID:               id,
		Position:         position,
		FilePath:         filePath,
		Title:            title,
		Artist:           artist,
		Album:            album,
		Duration:         strconv.FormatInt(lengthMilliseconds, 10),
		ArtistMBID:       artistMBID,
		ReleaseGroupMBID: releaseGroupMBID,
		RecordingMBID:    recordingMBID,
	}

	if coverArtPath != "" {
		urls := coverart.ResolveURLs(coverArtPath)
		track.CoverArtPath = urls.Original
		track.CoverArtSmall = urls.Small
		track.CoverArtMedium = urls.Medium
		track.CoverArtLarge = urls.Large
	}

	return track
}

// CreatePlaylist creates a new empty playlist with the given name.
func (s *Service) CreatePlaylist(
	name string,
) (Summary, error) {
	trimmed := strings.TrimSpace(name)
	if trimmed == "" {
		return Summary{}, errEmptyName
	}

	created, err := s.db.Queries.CreatePlaylist(
		s.db.Ctx, trimmed,
	)
	if err != nil {
		s.logger.Error(
			"Failed to create playlist",
			"name", trimmed, "err", err,
		)

		return Summary{}, fmt.Errorf(
			"failed to create playlist: %w", err,
		)
	}

	s.logger.Info(
		"Playlist created",
		"id", created.ID, "name", created.Name,
	)

	s.savePlaylistFile(created.ID, created.Name)
	s.emitEvent(events.PlaylistCreated, Summary{
		ID:        created.ID,
		Name:      created.Name,
		CreatedAt: created.CreatedAt.Format(time.RFC3339),
		UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
		IsSmart:   created.IsSmart != 0,
	})

	return Summary{
		ID:        created.ID,
		Name:      created.Name,
		CreatedAt: created.CreatedAt.Format(time.RFC3339),
		UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
		IsSmart:   created.IsSmart != 0,
	}, nil
}

// AddTracksToPlaylist adds one or more tracks to an existing
// playlist.
func (s *Service) AddTracksToPlaylist(
	playlistID int64,
	filePaths []string,
) error {
	if len(filePaths) == 0 {
		return errNoFilePaths
	}

	nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
		s.db.Ctx,
		playlistID,
	)
	if err != nil {
		s.logger.Error(
			"Failed to get next position",
			"playlistId", playlistID,
			"err", err,
		)

		return fmt.Errorf(
			"failed to get next track position: %w", err,
		)
	}

	for i, fp := range filePaths {
		if err := s.addSingleTrack(
			playlistID, fp, nextPos+int64(i),
		); err != nil {
			return err
		}
	}

	s.logger.Info(
		"Tracks added to playlist",
		"playlistId", playlistID,
		"count", len(filePaths),
	)

	s.savePlaylistFileByID(playlistID)
	s.emitEvent(events.PlaylistTracksChanged, playlistID)

	return nil
}

// FindDuplicateTracksInPlaylist checks which of the given file
// paths already exist in the specified playlist. Returns metadata
// for each duplicate and a list of non-duplicate file paths.
func (s *Service) FindDuplicateTracksInPlaylist(
	playlistID int64,
	filePaths []string,
) (DuplicateCheckResult, error) {
	rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
		s.db.Ctx,
		playlistID,
	)
	if err != nil {
		s.logger.Error(
			"Failed to get playlist tracks for duplicate check",
			"playlistId", playlistID,
			"err", err,
		)

		return DuplicateCheckResult{}, fmt.Errorf(
			"failed to get playlist tracks: %w", err,
		)
	}

	existingPaths := make(
		map[string]sqlcgen.GetPlaylistTracksWithMetadataRow,
		len(rows),
	)

	for _, row := range rows {
		existingPaths[row.FilePath] = row
	}

	var duplicates []DuplicateTrackInfo

	var unique []string

	for _, fp := range filePaths {
		if row, exists := existingPaths[fp]; exists {
			duplicates = append(duplicates, DuplicateTrackInfo{
				FilePath: fp,
				Title:    row.Title,
				Artist:   row.Artist,
				Album:    row.Album,
				Duration: strconv.FormatInt(
					row.LengthMilliseconds, 10,
				),
			})
		} else {
			unique = append(unique, fp)
		}
	}

	return DuplicateCheckResult{
		Duplicates: duplicates,
		Unique:     unique,
	}, nil
}

// CreatePlaylistWithTracks creates a new playlist and populates
// it with tracks.
func (s *Service) CreatePlaylistWithTracks(
	name string,
	filePaths []string,
) (Summary, error) {
	trimmed := strings.TrimSpace(name)
	if trimmed == "" {
		return Summary{}, errEmptyName
	}

	created, err := s.db.Queries.CreatePlaylist(
		s.db.Ctx, trimmed,
	)
	if err != nil {
		s.logger.Error(
			"Failed to create playlist",
			"name", trimmed, "err", err,
		)

		return Summary{}, fmt.Errorf(
			"failed to create playlist: %w", err,
		)
	}

	if len(filePaths) > 0 {
		nextPos, posErr := s.db.Queries.GetNextPlaylistTrackPosition(
			s.db.Ctx,
			created.ID,
		)
		if posErr != nil {
			return Summary{}, fmt.Errorf(
				"failed to get next track position: %w",
				posErr,
			)
		}

		for i, fp := range filePaths {
			if err := s.addSingleTrack(
				created.ID, fp, nextPos+int64(i),
			); err != nil {
				return Summary{}, fmt.Errorf(
					"playlist created but failed to add tracks: %w",
					err,
				)
			}
		}
	}

	summary := Summary{
		ID:        created.ID,
		Name:      created.Name,
		CreatedAt: created.CreatedAt.Format(time.RFC3339),
		UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
		IsSmart:   created.IsSmart != 0,
	}

	s.logger.Info(
		"Playlist created with tracks",
		"id", created.ID,
		"name", created.Name,
		"trackCount", len(filePaths),
	)

	s.savePlaylistFile(created.ID, created.Name)
	s.emitEvent(events.PlaylistCreated, summary)

	return summary, nil
}

// RemoveTracksFromPlaylist removes multiple tracks from a playlist
// by their playlist_track IDs.
func (s *Service) RemoveTracksFromPlaylist(
	playlistID int64,
	trackIDs []int64,
) error {
	if len(trackIDs) == 0 {
		return nil
	}

	for _, id := range trackIDs {
		if err := s.db.Queries.RemovePlaylistTrack(
			s.db.Ctx,
			id,
		); err != nil {
			s.logger.Error(
				"Failed to remove playlist track",
				"playlistId", playlistID,
				"trackId", id,
				"err", err,
			)

			return fmt.Errorf(
				"failed to remove track %d from playlist: %w",
				id,
				err,
			)
		}
	}

	s.logger.Info(
		"Tracks removed from playlist",
		"playlistId", playlistID,
		"count", len(trackIDs),
	)

	s.savePlaylistFileByID(playlistID)
	s.emitEvent(events.PlaylistTracksChanged, playlistID)

	return nil
}

// DeletePlaylist deletes a playlist and its M3U8 file.
// If the deleted playlist was the default, a new default
// playlist is automatically created.
func (s *Service) DeletePlaylist(playlistID int64) error {
	if err := s.db.Queries.DeletePlaylist(
		s.db.Ctx, playlistID,
	); err != nil {
		s.logger.Error(
			"Failed to delete playlist",
			"playlistId", playlistID,
			"err", err,
		)

		return fmt.Errorf(
			"failed to delete playlist: %w", err,
		)
	}

	s.deletePlaylistFile(playlistID)

	s.logger.Info(
		"Playlist deleted", "playlistId", playlistID,
	)

	s.emitEvent(events.PlaylistDeleted, playlistID)

	// Recreate the default playlist if we just deleted it.
	if s.defaultPlaylistID() == playlistID {
		s.EnsureDefaultPlaylist()
	}

	return nil
}

// RenamePlaylist renames a playlist and updates its M3U8 file.
func (s *Service) RenamePlaylist(
	playlistID int64,
	newName string,
) error {
	trimmed := strings.TrimSpace(newName)
	if trimmed == "" {
		return errEmptyName
	}

	if err := s.db.Queries.UpdatePlaylistName(
		s.db.Ctx,
		sqlcgen.UpdatePlaylistNameParams{
			Name: trimmed,
			ID:   playlistID,
		},
	); err != nil {
		s.logger.Error(
			"Failed to rename playlist",
			"playlistId", playlistID,
			"newName", trimmed,
			"err", err,
		)

		return fmt.Errorf(
			"failed to rename playlist: %w", err,
		)
	}

	// Re-save the M3U8 file with the new name (handles rename
	// of the file on disk).
	s.savePlaylistFile(playlistID, trimmed)

	s.logger.Info(
		"Playlist renamed",
		"playlistId", playlistID,
		"newName", trimmed,
	)

	s.emitEvent(events.PlaylistRenamed, Summary{
		ID:   playlistID,
		Name: trimmed,
	})

	return nil
}

// uniquePlaylistName returns a name that doesn't collide with existing
// playlists. If "Chill Vibes" exists, returns "Chill Vibes (1)".
// If that also exists, returns "Chill Vibes (2)", etc.
func (s *Service) uniquePlaylistName(name string) string {
	count, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, name)
	if err != nil || count == 0 {
		return name
	}

	for i := 1; ; i++ {
		candidate := fmt.Sprintf("%s (%d)", name, i)

		c, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, candidate)
		if err != nil || c == 0 {
			return candidate
		}
	}
}

// ImportPlaylist imports a playlist from an external M3U/M3U8
// file. It creates a new playlist in the DB, resolves tracks
// against the library, and saves an M3U8 file.
func (s *Service) ImportPlaylist(
	filePath string,
) (Summary, error) {
	if strings.TrimSpace(filePath) == "" {
		return Summary{}, errEmptyFilePath
	}

	ext := filepath.Ext(filePath)
	if !isValidM3UExtension(ext) {
		return Summary{}, fmt.Errorf(
			"%w: %q, expected .m3u or .m3u8",
			errUnsupportedFileType, ext,
		)
	}

	parsed, err := parseM3U8(filePath)
	if err != nil {
		return Summary{}, fmt.Errorf(
			"could not parse playlist file: %w", err,
		)
	}

	playlistName := parsed.Name
	if playlistName == "" {
		base := filepath.Base(filePath)
		playlistName = strings.TrimSuffix(
			base, filepath.Ext(base),
		)
	}

	playlistName = s.uniquePlaylistName(playlistName)

	// Create playlist in DB.
	created, err := s.db.Queries.CreatePlaylist(
		s.db.Ctx, playlistName,
	)
	if err != nil {
		return Summary{}, fmt.Errorf(
			"could not create playlist for import: %w", err,
		)
	}

	libraryRoots := s.getAllLibraryRoots()

	var (
		resolved   int
		unresolved int
		position   int
	)

	for _, entry := range parsed.Entries {
		var audioFile sqlcgen.AudioFile

		found := false

		if filepath.IsAbs(entry.RelativePath) {
			af, err := s.db.Queries.GetAudioFileByPath(
				s.db.Ctx, entry.RelativePath,
			)
			if err == nil {
				audioFile = af
				found = true
			}
		} else {
			for _, root := range libraryRoots {
				absPath := toAbsolutePath(
					entry.RelativePath, root,
				)

				af, err := s.db.Queries.GetAudioFileByPath(
					s.db.Ctx, absPath,
				)
				if err == nil {
					audioFile = af
					found = true

					break
				}
			}
		}

		if !found {
			// Track not in library — will appear as phantom.
			unresolved++

			continue
		}

		_, addErr := s.db.Queries.AddPlaylistTrack(
			s.db.Ctx,
			sqlcgen.AddPlaylistTrackParams{
				PlaylistID:  created.ID,
				AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
				Position:    int64(position),
			},
		)
		if addErr != nil {
			s.logger.Warn(
				"Could not add imported track",
				"playlistId", created.ID,
				"path", audioFile.FilePath,
				"err", addErr,
			)

			continue
		}

		position++
		resolved++
	}

	// Save the M3U8 file with entries (preserves unresolved
	// paths for phantom display).
	s.saveImportedPlaylistFile(
		created.ID, playlistName, parsed.Entries, libraryRoots,
	)

	s.logger.Info(
		"Playlist imported",
		"id", created.ID,
		"name", playlistName,
		"resolved", resolved,
		"unresolved", unresolved,
	)

	summary := Summary{
		ID:        created.ID,
		Name:      playlistName,
		CreatedAt: created.CreatedAt.Format(time.RFC3339),
		UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
		IsSmart:   created.IsSmart != 0,
	}

	s.emitEvent(events.PlaylistCreated, summary)

	return summary, nil
}

// ImportPlaylists imports multiple playlists from external M3U/M3U8
// files. Each file is imported sequentially using ImportPlaylist.
// Errors from individual imports are collected; partial success is
// possible. Returns the summaries of successfully imported playlists
// and the first error encountered (if any).
func (s *Service) ImportPlaylists(
	filePaths []string,
) ([]Summary, error) {
	if len(filePaths) == 0 {
		return nil, errNoFilePaths
	}

	summaries := make([]Summary, 0, len(filePaths))

	var firstErr error

	for _, fp := range filePaths {
		summary, err := s.ImportPlaylist(fp)
		if err != nil {
			s.logger.Warn(
				"Failed to import playlist file",
				"path", fp,
				"err", err,
			)

			if firstErr == nil {
				firstErr = fmt.Errorf(
					"import %q failed: %w", fp, err,
				)
			}

			continue
		}

		summaries = append(summaries, summary)
	}

	return summaries, firstErr
}

// RestoreAllPlaylists restores playlist tracks from M3U8 files.
// This is called after a full library rescan to repopulate
// playlist_tracks from the surviving M3U8 files.
func (s *Service) RestoreAllPlaylists() {
	dir, err := s.playlistsDir()
	if err != nil {
		s.logger.Warn(
			"Could not get playlists dir for restore",
			"err", err,
		)

		return
	}

	files, err := listPlaylistFiles(dir)
	if err != nil {
		s.logger.Warn(
			"Could not list playlist files",
			"err", err,
		)

		return
	}

	if len(files) == 0 {
		return
	}

	libraryRoots := s.getAllLibraryRoots()

	var totalRestored, totalUnresolved int

	for _, file := range files {
		playlistID := extractPlaylistID(file)
		if playlistID == 0 {
			s.logger.Warn(
				"Could not extract playlist ID from filename",
				"file", file,
			)

			continue
		}

		restored, unresolved := s.restoreSinglePlaylist(
			playlistID, file, libraryRoots,
		)

		totalRestored += restored
		totalUnresolved += unresolved
	}

	s.logger.Info(
		"All playlists restored from M3U8 files",
		"totalRestored", totalRestored,
		"totalUnresolved", totalUnresolved,
	)

	s.emitEvent(events.PlaylistsRestored, nil)
}

// restoreSinglePlaylist restores tracks for a single playlist
// from its M3U8 file. Each M3U8 entry is resolved against all
// library roots.
func (s *Service) restoreSinglePlaylist(
	playlistID int64,
	m3uPath string,
	libraryRoots []string,
) (restored, unresolved int) {
	parsed, err := parseM3U8(m3uPath)
	if err != nil {
		s.logger.Warn(
			"Could not parse M3U8 for restore",
			"playlistId", playlistID,
			"path", m3uPath,
			"err", err,
		)

		return 0, 0
	}

	// Verify the playlist exists in the DB.
	_, err = s.db.Queries.GetPlaylist(
		s.db.Ctx, playlistID,
	)
	if err != nil {
		s.logger.Warn(
			"Playlist not found in DB during restore",
			"playlistId", playlistID,
			"err", err,
		)

		return 0, 0
	}

	var position int

	for _, entry := range parsed.Entries {
		var audioFile sqlcgen.AudioFile

		found := false

		if filepath.IsAbs(entry.RelativePath) {
			af, lookupErr := s.db.Queries.GetAudioFileByPath(
				s.db.Ctx, entry.RelativePath,
			)
			if lookupErr == nil {
				audioFile = af
				found = true
			}
		} else {
			for _, root := range libraryRoots {
				absPath := toAbsolutePath(
					entry.RelativePath, root,
				)

				af, lookupErr := s.db.Queries.GetAudioFileByPath(
					s.db.Ctx, absPath,
				)
				if lookupErr == nil {
					audioFile = af
					found = true

					break
				}
			}
		}

		if !found {
			unresolved++

			continue
		}

		_, addErr := s.db.Queries.AddPlaylistTrack(
			s.db.Ctx,
			sqlcgen.AddPlaylistTrackParams{
				PlaylistID:  playlistID,
				AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
				Position:    int64(position),
			},
		)
		if addErr != nil {
			s.logger.Warn(
				"Could not restore track",
				"playlistId", playlistID,
				"path", audioFile.FilePath,
				"err", addErr,
			)

			continue
		}

		position++
		restored++
	}

	s.logger.Info(
		"Playlist restored",
		"playlistId", playlistID,
		"restored", restored,
		"unresolved", unresolved,
	)

	return restored, unresolved
}

// addSingleTrack looks up the audio file by path and inserts it
// into the playlist.
func (s *Service) addSingleTrack(
	playlistID int64,
	filePath string,
	position int64,
) error {
	if strings.TrimSpace(filePath) == "" {
		return errEmptyFilePath
	}

	audioFile, err := s.db.Queries.GetAudioFileByPath(
		s.db.Ctx, filePath,
	)
	if err != nil {
		s.logger.Error(
			"Failed to find audio file",
			"filePath", filePath,
			"err", err,
		)

		return fmt.Errorf(
			"failed to find audio file %q: %w",
			filePath, err,
		)
	}

	_, err = s.db.Queries.AddPlaylistTrack(
		s.db.Ctx,
		sqlcgen.AddPlaylistTrackParams{
			PlaylistID:  playlistID,
			AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
			Position:    position,
		},
	)
	if err != nil {
		s.logger.Error(
			"Failed to add track to playlist",
			"playlistId", playlistID,
			"audioFileId", audioFile.ID,
			"err", err,
		)

		return fmt.Errorf(
			"failed to add track to playlist: %w", err,
		)
	}

	return nil
}

// --- M3U8 file management helpers ---

// playlistsDir returns the path to the playlists directory,
// creating it if needed.
func (s *Service) playlistsDir() (string, error) {
	dataDir := s.dataDirOverride

	if dataDir == "" {
		var err error

		dataDir, err = system.GetUserDataDirPath()
		if err != nil {
			return "", fmt.Errorf(
				"could not get user data directory: %w", err,
			)
		}
	}

	dir := filepath.Join(dataDir, playlistsDirName)

	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
		return "", fmt.Errorf(
			"could not create playlists directory: %w", err,
		)
	}

	return dir, nil
}

// getAllLibraryRoots returns the root directory paths of all
// configured libraries. It first checks the legacy config
// DirectoryPath; if that is set, it returns a single-element
// slice. Otherwise it queries all libraries from the database.
func (s *Service) getAllLibraryRoots() []string {
	// Legacy config fallback.
	if s.libraryDir != nil {
		if dir := s.libraryDir.GetLibraryDirectory(); dir != "" {
			return []string{dir}
		}
	}

	libs, err := s.db.Queries.GetAllLibraries(s.db.Ctx)
	if err != nil || len(libs) == 0 {
		return nil
	}

	roots := make([]string, len(libs))
	for i, lib := range libs {
		roots[i] = lib.Path
	}

	return roots
}

// savePlaylistFile saves the current state of a playlist to its
// M3U8 file.
func (s *Service) savePlaylistFile(
	playlistID int64,
	name string,
) {
	dir, err := s.playlistsDir()
	if err != nil {
		s.logger.Warn(
			"Could not get playlists dir for save",
			"err", err,
		)

		return
	}

	entries := s.buildM3UEntries(playlistID)

	if err := writeM3U8(
		dir, playlistID, name, entries,
	); err != nil {
		s.logger.Warn(
			"Could not save playlist M3U8 file",
			"playlistId", playlistID,
			"err", err,
		)
	}
}

// savePlaylistFileByID looks up the playlist name and saves.
func (s *Service) savePlaylistFileByID(playlistID int64) {
	playlist, err := s.db.Queries.GetPlaylist(
		s.db.Ctx, playlistID,
	)
	if err != nil {
		s.logger.Warn(
			"Could not get playlist for save",
			"playlistId", playlistID,
			"err", err,
		)

		return
	}

	s.savePlaylistFile(playlistID, playlist.Name)
}

// saveImportedPlaylistFile saves an M3U8 file for an imported
// playlist, preserving the original entries (including
// unresolved paths).
func (s *Service) saveImportedPlaylistFile(
	playlistID int64,
	name string,
	entries []m3uEntry,
	libraryRoots []string,
) {
	dir, err := s.playlistsDir()
	if err != nil {
		s.logger.Warn(
			"Could not get playlists dir for import save",
			"err", err,
		)

		return
	}

	// Convert any absolute paths in entries to relative
	// using the correct library root for each track.
	converted := make([]m3uEntry, len(entries))

	for i, entry := range entries {
		// Resolve against all roots to get the absolute path,
		// then convert back to relative using the matching root.
		absPath := resolveM3UPath(
			entry.RelativePath,
			libraryRoots,
			nil,
		)

		converted[i] = m3uEntry{
			RelativePath: toRelativePathMultiRoot(
				absPath, libraryRoots,
			),
			DurationSec:  entry.DurationSec,
			DisplayTitle: entry.DisplayTitle,
		}
	}

	if err := writeM3U8(
		dir, playlistID, name, converted,
	); err != nil {
		s.logger.Warn(
			"Could not save imported playlist M3U8 file",
			"playlistId", playlistID,
			"err", err,
		)
	}
}

// buildM3UEntries builds M3U entries from the current DB state
// of a playlist.
func (s *Service) buildM3UEntries(
	playlistID int64,
) []m3uEntry {
	rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
		s.db.Ctx,
		playlistID,
	)
	if err != nil {
		s.logger.Warn(
			"Could not get tracks for M3U build",
			"playlistId", playlistID,
			"err", err,
		)

		return nil
	}

	libraryRoots := s.getAllLibraryRoots()
	entries := make([]m3uEntry, 0, len(rows))

	for _, row := range rows {
		durationSec := int(
			row.LengthMilliseconds / 1000,
		)

		entries = append(entries, m3uEntry{
			RelativePath: toRelativePathMultiRoot(
				row.FilePath, libraryRoots,
			),
			DurationSec: durationSec,
			DisplayTitle: displayTitle(
				row.Artist, row.Title,
			),
		})
	}

	return entries
}

// deletePlaylistFile removes the M3U8 file for a playlist.
func (s *Service) deletePlaylistFile(playlistID int64) {
	dir, err := s.playlistsDir()
	if err != nil {
		return
	}

	existing, err := findPlaylistFile(dir, playlistID)
	if err != nil || existing == "" {
		return
	}

	if err := os.Remove(existing); err != nil &&
		!os.IsNotExist(err) {
		s.logger.Warn(
			"Could not delete playlist file",
			"playlistId", playlistID,
			"path", existing,
			"err", err,
		)
	}
}

// emitEvent emits a Wails event if the context is available.
func (s *Service) emitEvent(
	eventName string,
	data any,
) {
	events.Emit(s.ctx, eventName, data)
}

// migrateExistingPlaylists generates M3U8 files for any
// existing DB playlists that don't already have one. This runs
// once at startup to bootstrap the file-based backup for users
// who already have playlists.
func (s *Service) migrateExistingPlaylists() {
	dir, err := s.playlistsDir()
	if err != nil {
		s.logger.Warn(
			"Could not get playlists dir for migration",
			"err", err,
		)

		return
	}

	existingFiles, err := listPlaylistFiles(dir)
	if err != nil {
		s.logger.Warn(
			"Could not list existing playlist files",
			"err", err,
		)

		return
	}

	// Build a set of IDs that already have files.
	existingIDs := make(map[int64]struct{})

	for _, file := range existingFiles {
		id := extractPlaylistID(file)
		if id > 0 {
			existingIDs[id] = struct{}{}
		}
	}

	playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
	if err != nil {
		s.logger.Warn(
			"Could not get playlists for migration",
			"err", err,
		)

		return
	}

	var migrated int

	for _, p := range playlists {
		if _, exists := existingIDs[p.ID]; exists {
			continue
		}

		s.savePlaylistFile(p.ID, p.Name)

		migrated++
	}

	if migrated > 0 {
		s.logger.Info(
			"Migrated existing playlists to M3U8 files",
			"count", migrated,
		)
	}
}

// =================================================================
// Playlist repopulation from M3U8
// =================================================================

// RepopulateFromM3U re-imports tracks for playlists that have zero
// playlist_tracks rows but still have a corresponding M3U8 file.
// This recovers from a FullRescan that deleted playlist tracks
// before the ON DELETE SET NULL fix was in place.  Each M3U8 entry
// is resolved against the audio_files table; unresolved entries
// become phantom tracks with metadata preserved from the M3U8.
func (s *Service) RepopulateFromM3U() {
	dir, err := s.playlistsDir()
	if err != nil {
		s.logger.Warn(
			"could not get playlists dir for repopulation",
			"err", err,
		)

		return
	}

	// Get all playlists.
	playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
	if err != nil {
		s.logger.Warn("could not get playlists for repopulation", "err", err)

		return
	}

	libraryRoots := s.getAllLibraryRoots()

	// Build audio file path→ID map for resolution.
	afRows, err := s.db.QueryContext(
		`SELECT id, file_path FROM audio_files`,
	)
	if err != nil {
		s.logger.Warn("could not query audio files for repopulation", "err", err)

		return
	}

	audioFileByPath := make(map[string]int64)

	for afRows.Next() {
		var (
			id int64
			fp string
		)

		if err := afRows.Scan(&id, &fp); err != nil {
			continue
		}

		audioFileByPath[fp] = id
	}

	_ = afRows.Close()

	knownPaths := make(map[string]struct{}, len(audioFileByPath))
	for k := range audioFileByPath {
		knownPaths[k] = struct{}{}
	}

	var totalRepopulated int

	for _, pl := range playlists {
		// Only repopulate playlists with zero tracks.
		countRows, err := s.db.QueryContext(
			`SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ?`,
			pl.ID,
		)
		if err != nil {
			continue
		}

		var count int
		if countRows.Next() {
			_ = countRows.Scan(&count)
		}

		_ = countRows.Close()

		if count > 0 {
			continue
		}

		m3uPath, err := findPlaylistFile(dir, pl.ID)
		if err != nil || m3uPath == "" {
			continue
		}

		parsed, err := parseM3U8(m3uPath)
		if err != nil {
			s.logger.Warn(
				"could not parse M3U8 for repopulation",
				"playlistId", pl.ID,
				"path", m3uPath,
				"err", err,
			)

			continue
		}

		var resolved, phantom int

		for i, entry := range parsed.Entries {
			absPath := resolveM3UPath(
				entry.RelativePath, libraryRoots, knownPaths,
			)

			audioFileID, exists := audioFileByPath[absPath]

			if exists {
				// Linked track.
				_, addErr := s.db.ExecContext(
					`INSERT INTO playlist_tracks
						(playlist_id, audio_file_id, position)
					VALUES (?, ?, ?)`,
					pl.ID, audioFileID, i,
				)
				if addErr != nil {
					s.logger.Warn(
						"could not add repopulated track",
						"playlistId", pl.ID,
						"position", i,
						"err", addErr,
					)

					continue
				}

				resolved++
			} else {
				// Phantom track — preserve what we have from M3U8.
				_, addErr := s.db.ExecContext(
					`INSERT INTO playlist_tracks
						(playlist_id, position, phantom_title, phantom_file_path)
					VALUES (?, ?, ?, ?)`,
					pl.ID, i, entry.DisplayTitle, absPath,
				)
				if addErr != nil {
					s.logger.Warn(
						"could not add phantom repopulated track",
						"playlistId", pl.ID,
						"position", i,
						"err", addErr,
					)

					continue
				}

				phantom++
			}
		}

		if resolved+phantom > 0 {
			totalRepopulated += resolved + phantom
			s.logger.Info(
				"repopulated playlist from M3U8",
				"playlistId", pl.ID,
				"name", pl.Name,
				"resolved", resolved,
				"phantom", phantom,
			)
		}
	}

	if totalRepopulated > 0 {
		s.logger.Info(
			"playlist repopulation complete",
			"totalTracks", totalRepopulated,
		)
		s.emitEvent(events.PlaylistTracksChanged, nil)
	}
}

// =================================================================
// Phantom track resolution
// =================================================================

// ResolvePhantomTracksAfterScan re-links phantom playlist tracks
// whose files now exist in the library.  It iterates each playlist
// that has phantoms, reads its M3U8 file, resolves each entry
// against the current audio_files table using multi-root path
// resolution, and updates matching phantom playlist_tracks.  This
// handles both pre-existing phantoms (created before migration 7,
// with NULL phantom_file_path) and new ones.
func (s *Service) ResolvePhantomTracksAfterScan() {
	// 1. Get distinct playlist IDs that have phantom tracks.
	// SAFETY: Hand-crafted SELECT for phantom playlist IDs.
	// No user input — reads only system state.
	rows, err := s.db.QueryContext(
		`SELECT DISTINCT playlist_id
		 FROM playlist_tracks
		 WHERE audio_file_id IS NULL`,
	)
	if err != nil {
		s.logger.Warn(
			"could not query phantom playlists",
			"err", err,
		)

		return
	}

	var phantomPlaylistIDs []int64

	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			s.logger.Warn(
				"could not scan phantom playlist ID",
				"err", err,
			)

			continue
		}

		phantomPlaylistIDs = append(phantomPlaylistIDs, id)
	}

	if err := rows.Close(); err != nil {
		s.logger.Warn(
			"could not close phantom playlist rows",
			"err", err,
		)
	}

	if len(phantomPlaylistIDs) == 0 {
		return
	}

	// 2. Build audio file path→ID map for resolution.
	// SAFETY: Hand-crafted SELECT for full audio file path map.
	// No user input — reads only system state.
	afRows, err := s.db.QueryContext(
		`SELECT id, file_path FROM audio_files`,
	)
	if err != nil {
		s.logger.Warn(
			"could not query audio files for phantom resolution",
			"err", err,
		)

		return
	}

	audioFileByPath := make(map[string]int64)

	for afRows.Next() {
		var id int64

		var fp string
		if err := afRows.Scan(&id, &fp); err != nil {
			continue
		}

		audioFileByPath[fp] = id
	}

	if err := afRows.Close(); err != nil {
		s.logger.Warn(
			"could not close audio file rows",
			"err", err,
		)
	}

	if len(audioFileByPath) == 0 {
		return
	}

	// 3. Build knownPaths set for resolveM3UPath.
	knownPaths := make(
		map[string]struct{}, len(audioFileByPath),
	)
	for k := range audioFileByPath {
		knownPaths[k] = struct{}{}
	}

	libraryRoots := s.getAllLibraryRoots()

	dir, err := s.playlistsDir()
	if err != nil {
		s.logger.Warn(
			"could not get playlists dir for phantom resolution",
			"err", err,
		)

		return
	}

	var totalResolved int

	// 4. For each playlist with phantoms, resolve via M3U8.
	for _, playlistID := range phantomPlaylistIDs {
		resolved := s.resolvePlaylistPhantoms(
			playlistID, dir, libraryRoots,
			knownPaths, audioFileByPath,
		)
		totalResolved += resolved
	}

	if totalResolved > 0 {
		s.logger.Info(
			"resolved phantom playlist tracks after scan",
			"count", totalResolved,
		)

		s.emitEvent(events.PlaylistTracksChanged, nil)
	}
}

// phantomTrackRow holds the minimal fields needed to match a
// phantom playlist_track against an M3U8 entry.
type phantomTrackRow struct {
	id              int64
	position        int64
	phantomFilePath string
}

// phantomRowSet is a playlist's unresolved rows plus the ones a
// resolution pass has already consumed, so two matches cannot land on
// the same row.
type phantomRowSet struct {
	rows  []phantomTrackRow
	taken map[int64]struct{}
}

// loadPhantomRows reads the playlist's phantom rows — the tracks whose
// file was missing when the M3U8 was imported.
func (s *Service) loadPhantomRows(playlistID int64) *phantomRowSet {
	set := &phantomRowSet{taken: map[int64]struct{}{}}

	// SAFETY: Hand-crafted SELECT for phantom tracks with position and
	// phantom_file_path. Parameterized by playlist ID.
	rows, err := s.db.QueryContext(
		`SELECT id, position, COALESCE(phantom_file_path, '')
		 FROM playlist_tracks
		 WHERE playlist_id = ? AND audio_file_id IS NULL`,
		playlistID,
	)
	if err != nil {
		s.logger.Warn(
			"could not query phantom tracks",
			"playlistId", playlistID,
			"err", err,
		)

		return set
	}

	defer func() {
		if closeErr := rows.Close(); closeErr != nil {
			s.logger.Warn(
				"could not close phantom track rows",
				"err", closeErr,
			)
		}
	}()

	for rows.Next() {
		var pt phantomTrackRow
		if err := rows.Scan(
			&pt.id, &pt.position, &pt.phantomFilePath,
		); err != nil {
			continue
		}

		set.rows = append(set.rows, pt)
	}

	return set
}

// takePhantomRow finds the phantom row standing for phantomAbs and
// marks it consumed.  It matches on phantom_file_path first and falls
// back to the row sitting at that entry's index in the M3U8, which is
// the same two-step resolvePlaylistPhantoms uses: rows imported before
// migration 7 carry no phantom_file_path at all.
func takePhantomRow(
	set *phantomRowSet,
	phantomAbs string,
	entries []m3uEntry,
	libraryRoots []string,
) (int64, bool) {
	_, idx := findM3UEntry(entries, phantomAbs, libraryRoots)

	return takePhantomRowAt(set, phantomAbs, idx)
}

// takePhantomRowAt is takePhantomRow for a caller that already knows
// the entry's index.  A negative index means "no positional fallback":
// positions are non-negative, so it simply never matches.
func takePhantomRowAt(
	set *phantomRowSet, phantomAbs string, index int,
) (int64, bool) {
	for _, pt := range set.rows {
		if _, done := set.taken[pt.id]; done {
			continue
		}

		if pt.phantomFilePath != "" &&
			pt.phantomFilePath == phantomAbs {
			set.taken[pt.id] = struct{}{}

			return pt.id, true
		}
	}

	for _, pt := range set.rows {
		if _, done := set.taken[pt.id]; done {
			continue
		}

		if pt.position == int64(index) {
			set.taken[pt.id] = struct{}{}

			return pt.id, true
		}
	}

	return 0, false
}

// fillPhantomRow points a phantom playlist_tracks row at a real audio
// file and drops the placeholder metadata it was displaying.  The row
// keeps its position, which is why resolving a phantom does not move
// the track to the end of the playlist.
func (s *Service) fillPhantomRow(
	playlistTrackID, audioFileID int64,
) error {
	// SAFETY: Hand-crafted UPDATE to resolve a phantom playlist track.
	// Sets audio_file_id and clears all phantom metadata columns.
	// Parameterized by ID.
	if _, err := s.db.ExecContext(
		`UPDATE playlist_tracks SET
			audio_file_id = ?,
			phantom_title = NULL,
			phantom_artist = NULL,
			phantom_album = NULL,
			phantom_duration_ms = NULL,
			phantom_genre = NULL,
			phantom_cover_art_path = NULL,
			phantom_file_path = NULL
		WHERE id = ?`,
		audioFileID, playlistTrackID,
	); err != nil {
		return fmt.Errorf(
			"could not resolve phantom track %d: %w",
			playlistTrackID, err,
		)
	}

	return nil
}

// resolvePlaylistPhantoms resolves phantom tracks for a single
// playlist by reading its M3U8 file and matching entries against
// the audio_files table.  Returns the number of resolved tracks.
func (s *Service) resolvePlaylistPhantoms(
	playlistID int64,
	dir string,
	libraryRoots []string,
	knownPaths map[string]struct{},
	audioFileByPath map[string]int64,
) int {
	m3uPath, err := findPlaylistFile(dir, playlistID)
	if err != nil || m3uPath == "" {
		return 0
	}

	parsed, err := parseM3U8(m3uPath)
	if err != nil {
		s.logger.Warn(
			"could not parse M3U8 for phantom resolution",
			"playlistId", playlistID,
			"path", m3uPath,
			"err", err,
		)

		return 0
	}

	// Load phantom tracks for this playlist.
	phantoms := s.loadPhantomRows(playlistID)

	if len(phantoms.rows) == 0 {
		return 0
	}

	var resolved int

	// For each M3U8 entry, resolve its path and try to match
	// a phantom track.
	for i, entry := range parsed.Entries {
		absPath := resolveM3UPath(
			entry.RelativePath, libraryRoots, knownPaths,
		)

		audioFileID, exists := audioFileByPath[absPath]
		if !exists {
			continue
		}

		// Find the phantom that corresponds to this entry:
		// phantom_file_path first, then this entry's index.
		ptID, found := takePhantomRowAt(phantoms, absPath, i)
		if !found {
			continue
		}

		if err := s.fillPhantomRow(ptID, audioFileID); err != nil {
			s.logger.Warn(
				"could not resolve phantom track",
				"playlistTrackId", ptID,
				"audioFileId", audioFileID,
				"err", err,
			)

			continue
		}

		resolved++
	}

	return resolved
}

// FindPhantomMatches searches the library for matches for the
// given phantom file paths. High-confidence matches are returned
// as auto-matched pairs; the rest remain in the unmatched list.
func (s *Service) FindPhantomMatches(
	playlistID int64,
	phantomPaths []string,
) (PhantomSearchResult, error) {
	if len(phantomPaths) == 0 {
		return PhantomSearchResult{}, nil
	}

	dir, err := s.playlistsDir()
	if err != nil {
		return PhantomSearchResult{}, fmt.Errorf(
			"could not get playlists dir: %w", err,
		)
	}

	libraryRoots := s.getAllLibraryRoots()

	// Load M3U8 entries for display title / duration data.
	m3uPath, err := findPlaylistFile(dir, playlistID)
	if err != nil {
		return PhantomSearchResult{}, fmt.Errorf(
			"could not find playlist file: %w", err,
		)
	}

	var entries []m3uEntry

	if m3uPath != "" {
		parsed, parseErr := parseM3U8(m3uPath)
		if parseErr == nil {
			entries = parsed.Entries
		}
	}

	// Build a lookup from absolute path to M3U entry.
	// Use resolveM3UPath with a nil knownPaths to get the
	// first-root fallback for each entry.
	entryByPath := make(map[string]m3uEntry, len(entries))

	for _, e := range entries {
		absPath := resolveM3UPath(
			e.RelativePath, libraryRoots, nil,
		)
		entryByPath[absPath] = e
	}

	// Every pairing confident enough to apply without asking.
	var offers []phantomOffer

	for _, phantomPath := range phantomPaths {
		entry := entryByPath[phantomPath]

		for _, c := range s.searchCandidates(phantomPath, entry) {
			if c.Score < autoMatchMinimum {
				// searchCandidates sorts by score, so nothing
				// below this one qualifies either.
				break
			}

			offers = append(offers, phantomOffer{
				phantomPath:  phantomPath,
				phantomTitle: entry.DisplayTitle,
				candidate:    c,
			})
		}
	}

	var result PhantomSearchResult

	result.AutoMatched = assignBestFirst(offers)

	matched := make(map[string]struct{}, len(result.AutoMatched))
	for _, m := range result.AutoMatched {
		matched[m.PhantomPath] = struct{}{}
	}

	// Reported in the order the playlist has them, not the order they
	// were matched in.
	for _, phantomPath := range phantomPaths {
		if _, done := matched[phantomPath]; done {
			continue
		}

		result.Unmatched = append(
			result.Unmatched, phantomPath,
		)
	}

	return result, nil
}

// phantomOffer is one candidate a phantom track could be resolved to,
// carrying enough to become a PhantomMatch.
type phantomOffer struct {
	phantomPath  string
	phantomTitle string
	candidate    CandidateTrack
}

// assignBestFirst pairs phantoms with candidates highest score first,
// at most one candidate per phantom and one phantom per candidate.
//
// A candidate can only stand in for one phantom — two would put that
// file in the playlist twice.  Taking the offers in *phantom* order,
// which is what this replaced, let an early phantom claim a candidate
// that was a better answer for a later one, so which of two similar
// tracks got the good match depended on the order they happen to sit in
// the playlist.
func assignBestFirst(offers []phantomOffer) []PhantomMatch {
	if len(offers) == 0 {
		return nil
	}

	ordered := make([]phantomOffer, len(offers))
	copy(ordered, offers)

	slices.SortStableFunc(
		ordered,
		func(a, b phantomOffer) int {
			return cmp.Compare(b.candidate.Score, a.candidate.Score)
		},
	)

	var matches []PhantomMatch

	claimedCandidates := make(map[string]struct{}, len(ordered))
	matchedPhantoms := make(map[string]struct{}, len(ordered))

	for _, o := range ordered {
		if _, taken := claimedCandidates[o.candidate.FilePath]; taken {
			continue
		}

		if _, done := matchedPhantoms[o.phantomPath]; done {
			continue
		}

		matches = append(matches, PhantomMatch{
			PhantomPath:  o.phantomPath,
			PhantomTitle: o.phantomTitle,
			Candidate:    o.candidate,
		})

		claimedCandidates[o.candidate.FilePath] = struct{}{}
		matchedPhantoms[o.phantomPath] = struct{}{}
	}

	return matches
}

// GetPhantomCandidates returns scored candidate matches for a
// single phantom track.
func (s *Service) GetPhantomCandidates(
	playlistID int64,
	phantomPath string,
) ([]CandidateTrack, error) {
	dir, err := s.playlistsDir()
	if err != nil {
		return nil, fmt.Errorf(
			"could not get playlists dir: %w", err,
		)
	}

	libraryRoots := s.getAllLibraryRoots()

	// Find the M3U entry for this phantom.
	m3uPath, err := findPlaylistFile(dir, playlistID)
	if err != nil {
		return nil, fmt.Errorf(
			"could not find playlist file: %w", err,
		)
	}

	var entry m3uEntry

	if m3uPath != "" {
		parsed, parseErr := parseM3U8(m3uPath)
		if parseErr == nil {
			entry, _ = findM3UEntry(
				parsed.Entries, phantomPath, libraryRoots,
			)
		}
	}

	return s.searchCandidates(
		phantomPath, entry,
	), nil
}

// SearchLibrary searches the entire library by a free-text query
// for manual phantom resolution.
func (s *Service) SearchLibrary(
	query string,
) ([]CandidateTrack, error) {
	trimmed := strings.TrimSpace(query)
	if trimmed == "" {
		return []CandidateTrack{}, nil
	}

	rows, err := s.db.SearchFTS(
		trimmed, maxLibrarySearchResults,
	)
	if err != nil {
		return nil, fmt.Errorf(
			"library search failed: %w", err,
		)
	}

	candidates := make([]CandidateTrack, 0, len(rows))

	for _, row := range rows {
		candidates = append(candidates, CandidateTrack{
			FilePath: row.FilePath,
			Title:    row.Title,
			Artist:   row.Artist,
			Album:    row.Album,
			Duration: strconv.FormatInt(
				row.LengthMilliseconds, 10,
			),
		})
	}

	return candidates, nil
}

// ResolvePhantomTracks replaces phantom entries in a playlist
// with real library tracks. The matches map keys are phantom
// absolute paths and values are resolved absolute paths.
func (s *Service) ResolvePhantomTracks(
	playlistID int64,
	matches map[string]string,
) error {
	if len(matches) == 0 {
		return nil
	}

	dir, err := s.playlistsDir()
	if err != nil {
		return fmt.Errorf(
			"could not get playlists dir: %w", err,
		)
	}

	libraryRoots := s.getAllLibraryRoots()

	m3uPath, err := findPlaylistFile(dir, playlistID)
	if err != nil || m3uPath == "" {
		return fmt.Errorf(
			"could not find M3U8 file for playlist %d: %w",
			playlistID, err,
		)
	}

	parsed, err := parseM3U8(m3uPath)
	if err != nil {
		return fmt.Errorf(
			"could not parse M3U8: %w", err,
		)
	}

	// Get next available DB position.
	nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
		s.db.Ctx, playlistID,
	)
	if err != nil {
		return fmt.Errorf(
			"could not get next position: %w", err,
		)
	}

	// The phantom rows this is about to fill in, so a resolution
	// updates the row the user pointed at rather than appending a
	// second one beside it.
	phantoms := s.loadPhantomRows(playlistID)

	// Build M3U path replacements and insert DB rows.
	pathReplacements := make(
		map[string]string, len(matches),
	)

	// Two phantoms resolving to one file would put that file in the
	// playlist twice, which is what FindPhantomMatches' claimed set
	// already refuses to do on the auto-match path.
	claimed := make(map[string]struct{}, len(matches))

	var (
		resolved int
		appended int
	)

	for phantomAbs, resolvedAbs := range matches {
		if _, taken := claimed[resolvedAbs]; taken {
			s.logger.Warn(
				"Two phantom tracks resolved to the same file",
				"playlistId", playlistID,
				"phantomPath", phantomAbs,
				"resolvedPath", resolvedAbs,
			)

			continue
		}

		audioFile, lookupErr := s.db.Queries.GetAudioFileByPath(
			s.db.Ctx, resolvedAbs,
		)
		if lookupErr != nil {
			s.logger.Warn(
				"Resolved path not found in library",
				"phantomPath", phantomAbs,
				"resolvedPath", resolvedAbs,
				"err", lookupErr,
			)

			continue
		}

		ptID, found := takePhantomRow(
			phantoms, phantomAbs, parsed.Entries, libraryRoots,
		)

		switch {
		case found:
			if updateErr := s.fillPhantomRow(
				ptID, audioFile.ID,
			); updateErr != nil {
				s.logger.Warn(
					"Could not resolve phantom row",
					"playlistId", playlistID,
					"playlistTrackId", ptID,
					"path", resolvedAbs,
					"err", updateErr,
				)

				continue
			}
		default:
			// No phantom row for this path — the M3U8 and the
			// database disagree, so fall back to appending.
			if _, addErr := s.db.Queries.AddPlaylistTrack(
				s.db.Ctx,
				sqlcgen.AddPlaylistTrackParams{
					PlaylistID:  playlistID,
					AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
					Position:    nextPos + int64(appended),
				},
			); addErr != nil {
				s.logger.Warn(
					"Could not add resolved track",
					"playlistId", playlistID,
					"path", resolvedAbs,
					"err", addErr,
				)

				continue
			}

			appended++
		}

		newRel := toRelativePathMultiRoot(
			resolvedAbs, libraryRoots,
		)
		pathReplacements[phantomAbs] = newRel
		claimed[resolvedAbs] = struct{}{}
		resolved++
	}

	// Rewrite the M3U8 with updated paths.
	if resolved > 0 {
		updated := replaceM3UEntryPaths(
			parsed.Entries, pathReplacements, libraryRoots,
		)

		playlist, nameErr := s.db.Queries.GetPlaylist(
			s.db.Ctx, playlistID,
		)
		if nameErr != nil {
			return fmt.Errorf(
				"could not get playlist name: %w", nameErr,
			)
		}

		if writeErr := writeM3U8(
			dir, playlistID, playlist.Name, updated,
		); writeErr != nil {
			return fmt.Errorf(
				"could not rewrite M3U8: %w", writeErr,
			)
		}
	}

	s.logger.Info(
		"Phantom tracks resolved",
		"playlistId", playlistID,
		"resolved", resolved,
		"requested", len(matches),
	)

	s.emitEvent(events.PlaylistTracksChanged, playlistID)

	return nil
}

// RemovePhantomTracks removes phantom entries from a playlist's
// M3U8 file. Since phantom tracks have no DB rows, only the
// M3U8 file is modified.
func (s *Service) RemovePhantomTracks(
	playlistID int64,
	phantomPaths []string,
) error {
	if len(phantomPaths) == 0 {
		return nil
	}

	dir, err := s.playlistsDir()
	if err != nil {
		return fmt.Errorf(
			"could not get playlists dir: %w", err,
		)
	}

	libraryRoots := s.getAllLibraryRoots()

	m3uPath, err := findPlaylistFile(dir, playlistID)
	if err != nil || m3uPath == "" {
		return fmt.Errorf(
			"could not find M3U8 file for playlist %d: %w",
			playlistID, err,
		)
	}

	parsed, err := parseM3U8(m3uPath)
	if err != nil {
		return fmt.Errorf(
			"could not parse M3U8: %w", err,
		)
	}

	targetSet := make(
		map[string]struct{}, len(phantomPaths),
	)

	for _, p := range phantomPaths {
		targetSet[p] = struct{}{}
	}

	updated := removeM3UEntries(
		parsed.Entries, targetSet, libraryRoots,
	)

	playlist, err := s.db.Queries.GetPlaylist(
		s.db.Ctx, playlistID,
	)
	if err != nil {
		return fmt.Errorf(
			"could not get playlist name: %w", err,
		)
	}

	if err := writeM3U8(
		dir, playlistID, playlist.Name, updated,
	); err != nil {
		return fmt.Errorf(
			"could not rewrite M3U8: %w", err,
		)
	}

	s.logger.Info(
		"Phantom tracks removed",
		"playlistId", playlistID,
		"removed", len(phantomPaths),
	)

	s.emitEvent(events.PlaylistTracksChanged, playlistID)

	return nil
}

// searchCandidates finds and scores candidate library tracks
// for a single phantom track.
func (s *Service) searchCandidates(
	phantomPath string,
	entry m3uEntry,
) []CandidateTrack {
	basename := filepath.Base(phantomPath)
	seen := make(map[string]struct{})

	var combined []database.SearchRow

	// 1. Exact basename match via indexed column.
	bnRows, err := s.db.Queries.SearchTracksByBasename(
		s.db.Ctx,
		sqlcgen.SearchTracksByBasenameParams{
			Basename: basename,
			Lim:      int64(maxCandidates),
		},
	)
	if err != nil {
		s.logger.Warn(
			"Basename search failed",
			"basename", basename,
			"err", err,
		)
	}

	for _, r := range bnRows {
		if _, ok := seen[r.FilePath]; ok {
			continue
		}

		seen[r.FilePath] = struct{}{}

		combined = append(combined, database.SearchRow{
			FilePath:           r.FilePath,
			LengthMilliseconds: r.LengthMilliseconds,
			Title:              r.Title,
			Artist:             r.ArtistName,
			Album:              r.Album,
		})
	}

	// 2. FTS5 filename-token search for fuzzy basename
	// matches (e.g. different extension).
	ftsFileRows, err := s.db.SearchFTSByFilename(
		basename, maxCandidates,
	)
	if err != nil {
		s.logger.Warn(
			"FTS filename search failed",
			"basename", basename,
			"err", err,
		)
	}

	for _, r := range ftsFileRows {
		if _, ok := seen[r.FilePath]; ok {
			continue
		}

		seen[r.FilePath] = struct{}{}

		combined = append(combined, r)
	}

	// 3. FTS5 keyword search from path + display title.
	keywords := extractKeywords(phantomPath)

	if entry.DisplayTitle != "" {
		titleKeywords := extractKeywords(
			entry.DisplayTitle,
		)
		keywords = append(keywords, titleKeywords...)
		keywords = dedupStrings(keywords)
	}

	if len(keywords) > 0 {
		kwQuery := strings.Join(keywords, " ")

		kwRows, kwErr := s.db.SearchFTS(
			kwQuery, maxCandidates,
		)
		if kwErr != nil {
			s.logger.Warn(
				"FTS keyword search failed",
				"keywords", keywords,
				"err", kwErr,
			)
		}

		for _, r := range kwRows {
			if _, ok := seen[r.FilePath]; ok {
				continue
			}

			seen[r.FilePath] = struct{}{}

			combined = append(combined, r)
		}
	}

	// Score each candidate.
	pp := newPhantomProfile(
		phantomPath, entry.DisplayTitle,
		entry.DurationSec,
	)

	candidates := make(
		[]CandidateTrack, 0, len(combined),
	)

	for _, row := range combined {
		score := scoreCandidate(
			pp,
			row.FilePath,
			row.Title,
			row.Artist,
			row.LengthMilliseconds,
		)

		candidates = append(candidates, CandidateTrack{
			FilePath: row.FilePath,
			Title:    row.Title,
			Artist:   row.Artist,
			Album:    row.Album,
			Duration: strconv.FormatInt(
				row.LengthMilliseconds, 10,
			),
			Score: score,
		})
	}

	// Sort by score descending.
	sortCandidatesByScore(candidates)

	if len(candidates) > maxCandidates {
		candidates = candidates[:maxCandidates]
	}

	return candidates
}

// sortCandidatesByScore sorts candidates by score descending.
func sortCandidatesByScore(candidates []CandidateTrack) {
	slices.SortFunc(
		candidates,
		func(a, b CandidateTrack) int {
			if a.Score > b.Score {
				return -1
			}

			if a.Score < b.Score {
				return 1
			}

			return 0
		},
	)
}

// =================================================================
// Smart playlist methods
// =================================================================

// errNotSmartPlaylist is returned when an operation that requires
// a smart playlist is performed on a regular playlist or a
// non-existent playlist.
var errNotSmartPlaylist = errors.New(
	"playlist not found or is not a smart playlist",
)

// errNoRowReturned is returned when an INSERT ... RETURNING
// query does not return the expected row.
var errNoRowReturned = errors.New(
	"no row returned from insert",
)

// CreateSmartPlaylist creates a new smart playlist with the given
// name and JSON rule set. The rules are validated before storage.
func (s *Service) CreateSmartPlaylist(
	name, rulesJSON string,
) (Summary, error) {
	trimmed := strings.TrimSpace(name)
	if trimmed == "" {
		return Summary{}, errEmptyName
	}

	// Validate rules JSON before storing.
	if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
		return Summary{}, fmt.Errorf(
			"invalid smart playlist rules: %w", err,
		)
	}

	var (
		id        int64
		retName   string
		createdAt string
		updatedAt string
	)

	// SAFETY: Hand-crafted INSERT for smart playlist with
	// is_smart and smart_rules columns not yet in sqlc schema.
	// All values are parameterized.
	//
	// QueryRowWriter, not QueryContext: this is an INSERT wearing a
	// query's shape, and QueryContext routes to the query-only read
	// pool.  Through that handle it failed with "attempt to write a
	// readonly database", i.e. no smart playlist could be created at
	// all.  A RETURNING clause does not make a write a read.
	if err := s.db.QueryRowWriter(
		`INSERT INTO playlists (name, is_smart, smart_rules)
		 VALUES (?, 1, ?)
		 RETURNING id, name, created_at, updated_at`,
		trimmed, rulesJSON,
	).Scan(&id, &retName, &createdAt, &updatedAt); err != nil {
		s.logger.Error(
			"Failed to create smart playlist",
			"name", trimmed, "err", err,
		)

		if errors.Is(err, sql.ErrNoRows) {
			return Summary{}, fmt.Errorf(
				"failed to create smart playlist: %w",
				errNoRowReturned,
			)
		}

		return Summary{}, fmt.Errorf(
			"failed to create smart playlist: %w", err,
		)
	}

	s.logger.Info(
		"Smart playlist created",
		"id", id, "name", retName,
	)

	// Materialize the rule set once at creation so the playlist has a
	// track snapshot immediately (track counts, instant open). A failed
	// evaluation is non-fatal — the lazy path re-materializes on first
	// open.
	if err := s.RefreshSmartPlaylist(id); err != nil {
		s.logger.Warn(
			"Failed to materialize smart playlist at creation",
			"id", id, "err", err,
		)
	}

	summary := Summary{
		ID:        id,
		Name:      retName,
		CreatedAt: createdAt,
		UpdatedAt: updatedAt,
		IsSmart:   true,
	}

	s.emitEvent(events.PlaylistCreated, summary)

	return summary, nil
}

// UpdateSmartPlaylistRules updates the rule set for an existing
// smart playlist. Returns an error if the playlist does not exist
// or is not a smart playlist.
func (s *Service) UpdateSmartPlaylistRules(
	playlistID int64,
	rulesJSON string,
) error {
	// Validate rules JSON before storing.
	if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
		return fmt.Errorf(
			"invalid smart playlist rules: %w", err,
		)
	}

	// SAFETY: Hand-crafted UPDATE for smart_rules column not
	// yet in sqlc schema. All values are parameterized.
	// Only updates rows where is_smart = 1.
	result, err := s.db.ExecContext(
		`UPDATE playlists
		 SET smart_rules = ?, updated_at = CURRENT_TIMESTAMP
		 WHERE id = ? AND is_smart = 1`,
		rulesJSON, playlistID,
	)
	if err != nil {
		s.logger.Error(
			"Failed to update smart playlist rules",
			"playlistId", playlistID, "err", err,
		)

		return fmt.Errorf(
			"failed to update smart playlist rules: %w", err,
		)
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf(
			"could not check rows affected: %w", err,
		)
	}

	if affected == 0 {
		return errNotSmartPlaylist
	}

	s.logger.Info(
		"Smart playlist rules updated",
		"playlistId", playlistID,
	)

	// Re-materialize the persisted snapshot so it reflects the new
	// rules. RefreshSmartPlaylist emits PlaylistTracksChanged.
	if err := s.RefreshSmartPlaylist(playlistID); err != nil {
		return fmt.Errorf(
			"failed to refresh smart playlist after rule update: %w",
			err,
		)
	}

	return nil
}

// RefreshSmartPlaylist re-evaluates a smart playlist's rules against
// the current library and replaces its persisted membership in
// playlist_tracks with the result. This is the only path that
// re-evaluates a smart playlist — opening one otherwise reads the
// stored snapshot. Triggered on rule save and by the manual Refresh
// button.
func (s *Service) RefreshSmartPlaylist(
	playlistID int64,
) error {
	// EvaluateSmartPlaylist validates that the playlist exists and is
	// smart, and returns the live rule-matched tracks.
	tracks, err := s.EvaluateSmartPlaylist(playlistID)
	if err != nil {
		return err
	}

	if err := s.db.Queries.ClearPlaylistTracks(
		s.db.Ctx, playlistID,
	); err != nil {
		return fmt.Errorf(
			"failed to clear smart playlist tracks: %w", err,
		)
	}

	for i, t := range tracks {
		if strings.TrimSpace(t.FilePath) == "" {
			continue
		}

		if err := s.addSingleTrack(
			playlistID, t.FilePath, int64(i),
		); err != nil {
			// A track can vanish between evaluation and insertion
			// (e.g. a concurrent rescan). Skip it rather than abort
			// the whole refresh.
			s.logger.Warn(
				"Skipping smart playlist track during refresh",
				"playlistId", playlistID,
				"filePath", t.FilePath,
				"err", err,
			)

			continue
		}
	}

	// SAFETY: Hand-crafted UPDATE for smart_snapshot_at column not
	// yet in sqlc schema. Parameterized by playlist ID.
	if _, err := s.db.ExecContext(
		`UPDATE playlists
		 SET smart_snapshot_at = CURRENT_TIMESTAMP
		 WHERE id = ? AND is_smart = 1`,
		playlistID,
	); err != nil {
		return fmt.Errorf(
			"failed to mark smart playlist snapshot: %w", err,
		)
	}

	s.logger.Info(
		"Smart playlist refreshed",
		"playlistId", playlistID,
		"trackCount", len(tracks),
	)

	s.savePlaylistFileByID(playlistID)
	s.emitEvent(events.PlaylistTracksChanged, playlistID)

	return nil
}

// GetSmartPlaylistTracks returns the persisted snapshot of a smart
// playlist as regular playlist tracks (with resolved cover art and
// phantom entries), identical to a normal playlist. If the playlist
// has never been materialized — e.g. it predates snapshot support —
// it is evaluated and stored on first access.
func (s *Service) GetSmartPlaylistTracks(
	playlistID int64,
) ([]Track, error) {
	// SAFETY: Hand-crafted SELECT for smart_snapshot_at column not
	// yet in sqlc schema. Parameterized by playlist ID.
	rows, err := s.db.QueryContext(
		`SELECT smart_snapshot_at FROM playlists
		 WHERE id = ? AND is_smart = 1`,
		playlistID,
	)
	if err != nil {
		return nil, fmt.Errorf(
			"failed to load smart playlist: %w", err,
		)
	}

	if !rows.Next() {
		_ = rows.Close()

		return nil, errNotSmartPlaylist
	}

	var snapshotAt sql.NullString

	if err := rows.Scan(&snapshotAt); err != nil {
		_ = rows.Close()

		return nil, fmt.Errorf(
			"failed to read smart playlist snapshot state: %w", err,
		)
	}

	// Close before RefreshSmartPlaylist / GetPlaylistTracks issue
	// their own queries (MaxOpenConns=1 test DBs would deadlock).
	_ = rows.Close()

	if !snapshotAt.Valid {
		if err := s.RefreshSmartPlaylist(playlistID); err != nil {
			return nil, err
		}
	}

	return s.GetPlaylistTracks(playlistID)
}

// MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any
// smart playlist that has never been materialized (smart_snapshot_at
// IS NULL) — e.g. playlists created before creation-time
// materialization existed. It runs once at startup and is idempotent:
// once every smart playlist has a snapshot it becomes a no-op. Errors
// on individual playlists are logged and skipped so one bad rule set
// doesn't block the rest.
func (s *Service) MaterializeUnmaterializedSmartPlaylists() {
	// SAFETY: Static SELECT for smart_snapshot_at column not yet in
	// sqlc schema. No parameters.
	rows, err := s.db.QueryContext(
		`SELECT id FROM playlists
		 WHERE is_smart = 1 AND smart_snapshot_at IS NULL`,
	)
	if err != nil {
		s.logger.Error(
			"Failed to list unmaterialized smart playlists",
			"err", err,
		)

		return
	}

	var ids []int64

	for rows.Next() {
		var id int64

		if err := rows.Scan(&id); err != nil {
			s.logger.Error(
				"Failed to scan smart playlist id", "err", err,
			)

			continue
		}

		ids = append(ids, id)
	}

	// Close before RefreshSmartPlaylist issues its own queries
	// (MaxOpenConns=1 test DBs would deadlock).
	_ = rows.Close()

	for _, id := range ids {
		if err := s.RefreshSmartPlaylist(id); err != nil {
			s.logger.Warn(
				"Failed to materialize smart playlist snapshot",
				"id", id, "err", err,
			)

			continue
		}
	}
}

// EvaluateSmartPlaylist loads the rule set for a smart playlist
// from the database and evaluates it against the track library,
// returning the matching tracks.
func (s *Service) EvaluateSmartPlaylist(
	playlistID int64,
) ([]library.Track, error) {
	// SAFETY: Hand-crafted SELECT for smart_rules column not
	// yet in sqlc schema. Parameterized by playlist ID.
	rows, err := s.db.QueryContext(
		`SELECT smart_rules FROM playlists
		 WHERE id = ? AND is_smart = 1`,
		playlistID,
	)
	if err != nil {
		return nil, fmt.Errorf(
			"failed to load smart playlist rules: %w", err,
		)
	}

	if !rows.Next() {
		_ = rows.Close()

		return nil, errNotSmartPlaylist
	}

	var rulesJSON string

	if err := rows.Scan(&rulesJSON); err != nil {
		_ = rows.Close()

		return nil, fmt.Errorf(
			"failed to load smart playlist rules: %w", err,
		)
	}

	// Close rows before calling Evaluate, which opens its own
	// query. With MaxOpenConns=1 (test DBs), a deferred close
	// would deadlock.
	_ = rows.Close()

	ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
	if err != nil {
		return nil, fmt.Errorf(
			"corrupt smart playlist rules for id %d: %w",
			playlistID, err,
		)
	}

	tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
	if err != nil {
		return nil, fmt.Errorf(
			"smart playlist evaluation failed for id %d: %w",
			playlistID, err,
		)
	}

	return tracks, nil
}

// PreviewSmartPlaylist evaluates a rule set from raw JSON without
// requiring a saved playlist. This powers live preview in the rule
// editor — the frontend sends rules as they are being edited and
// receives matching tracks immediately.
func (s *Service) PreviewSmartPlaylist(
	rulesJSON string,
) ([]library.Track, error) {
	ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
	if err != nil {
		return nil, fmt.Errorf(
			"invalid smart playlist rules: %w", err,
		)
	}

	tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
	if err != nil {
		return nil, fmt.Errorf(
			"smart playlist preview failed: %w", err,
		)
	}

	s.logger.Info(
		"Smart playlist preview evaluated",
		"trackCount", len(tracks),
	)

	return tracks, nil
}

// GetSmartPlaylistRules returns the raw JSON rule string for an
// existing smart playlist. This is used when the user opens the
// rule editor for an existing smart playlist.
func (s *Service) GetSmartPlaylistRules(
	playlistID int64,
) (string, error) {
	// SAFETY: Hand-crafted SELECT for smart_rules column not
	// yet in sqlc schema. Parameterized by playlist ID.
	rows, err := s.db.QueryContext(
		`SELECT smart_rules FROM playlists
		 WHERE id = ? AND is_smart = 1`,
		playlistID,
	)
	if err != nil {
		return "", fmt.Errorf(
			"failed to load smart playlist rules: %w", err,
		)
	}

	defer func() { _ = rows.Close() }()

	if !rows.Next() {
		return "", errNotSmartPlaylist
	}

	var rulesJSON string

	if err := rows.Scan(&rulesJSON); err != nil {
		return "", fmt.Errorf(
			"failed to scan smart playlist rules: %w", err,
		)
	}

	s.logger.Info(
		"Smart playlist rules loaded",
		"playlistId", playlistID,
	)

	return rulesJSON, nil
}
