package main

import (
	"bufio"
	"context"
	"crypto/sha1"
	"encoding/hex"
	"errors"
	"fmt"
	"os"
	"os/signal"
	"sort"
	"strings"
	"sync"
	"syscall"
	"time"

	"qtransfer/internal/config"
	"qtransfer/internal/jobconfig"
	"qtransfer/internal/match"
	"qtransfer/internal/model"
	"qtransfer/internal/qobuz"
	"qtransfer/internal/report"
	"qtransfer/internal/session"
	"qtransfer/internal/spotify"
	"qtransfer/internal/transfer"
	"qtransfer/internal/ui"
)

type sourceSelection struct {
	Playlists    []model.Playlist
	LikedSongs   []model.Track
	IncludeLiked bool
}

type monitorPlan struct {
	SyncMode         string
	TargetPlaylistID int64
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		os.Exit(1)
	}
}

func run() error {
	cfg, err := config.Load()
	if err != nil {
		return err
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	sess, err := session.Load(cfg.SessionPath)
	if err != nil {
		return err
	}
	if !cfg.RememberCreds {
		sess.Spotify = session.SpotifyState{}
		sess.Qobuz = session.QobuzState{}
	}
	if sess.Playlists == nil {
		sess.Playlists = map[string]session.PlaylistSyncRef{}
	}
	applySessionDefaults(&cfg, &sess)

	if cfg.Command == "login" {
		return runLoginCommand(ctx, &cfg, &sess)
	}
	if cfg.Command == "logout" {
		return runLogoutCommand(cfg)
	}

	if cfg.QobuzSelfTest {
		err := runQobuzSelfTest(ctx, cfg, &sess)
		_ = persistSession(cfg, sess)
		return err
	}

	fileCfg := jobconfig.File{}
	if strings.TrimSpace(cfg.ConfigFile) != "" {
		loaded, err := jobconfig.Load(cfg.ConfigFile)
		if err != nil {
			return err
		}
		fileCfg = loaded
		if err := applyFileGlobals(&cfg, fileCfg.Global); err != nil {
			return err
		}
	}

	planURLs, monitorPlans, err := buildPlaylistPlans(cfg, fileCfg.Playlists)
	if err != nil {
		return err
	}
	if len(planURLs) > 0 {
		cfg.PlaylistURLs = planURLs
	}

	spToken, err := authenticateSpotify(ctx, cfg, &sess)
	if err != nil {
		return fmt.Errorf("spotify auth failed: %w", err)
	}

	sp := spotify.NewClient(spToken.AccessToken)
	sp.SetProgress(func(msg string) {
		fmt.Printf("\r%-130s", msg)
	})

	selection, err := fetchSelection(ctx, cfg, sp)
	fmt.Print("\r")
	if err != nil {
		return err
	}

	if cfg.Monitor {
		if err := runMonitorMode(ctx, cfg, sp, &sess, selection, monitorPlans); err != nil {
			return err
		}
		return persistSession(cfg, sess)
	}

	fmt.Printf("Selected %d playlist(s)", len(selection.Playlists))
	if selection.IncludeLiked {
		fmt.Printf(" + liked songs (%d tracks)", len(selection.LikedSongs))
	}
	fmt.Println(".")

	qb, err := authenticateQobuz(ctx, cfg, &sess)
	if err != nil {
		return err
	}

	matcher := match.NewMatcher(qb)
	transferCfg := transfer.Config{
		DryRun:           cfg.DryRun,
		PublicPlaylists:  cfg.PublicPlaylists,
		Concurrency:      cfg.Concurrency,
		LikedName:        cfg.LikedPlaylist,
		TargetPlaylistID: cfg.TargetPlaylistID,
		Progress: func(msg string) {
			fmt.Printf("\r%-140s", msg)
		},
	}

	fmt.Println("Starting transfer...")
	start := time.Now()
	rep, err := transfer.Run(ctx, transferCfg, qb, matcher, selection.Playlists, selection.LikedSongs, selection.IncludeLiked)
	if err != nil {
		return fmt.Errorf("transfer failed: %w", err)
	}
	fmt.Print("\r")
	fmt.Println("Transfer processing complete.                                                                 ")

	if err := report.Write(cfg.ReportPath, rep); err != nil {
		return err
	}

	if err := persistSession(cfg, sess); err != nil {
		return err
	}

	printSummary(rep, cfg.ReportPath, time.Since(start), cfg.DryRun)
	return nil
}

func fetchSelection(ctx context.Context, cfg config.Config, sp *spotify.Client) (sourceSelection, error) {
	if len(cfg.PlaylistURLs) > 0 {
		fmt.Println("Fetching Spotify playlist URL selections...")
		ids, err := resolvePlaylistIDs(cfg.PlaylistURLs)
		if err != nil {
			return sourceSelection{}, err
		}
		playlists, err := sp.FetchPlaylistsByID(ctx, ids)
		if err != nil {
			return sourceSelection{}, fmt.Errorf("spotify fetch by URL failed: %w", err)
		}

		liked := []model.Track{}
		if cfg.IncludeLiked {
			liked, err = sp.FetchLikedSongs(ctx)
			if err != nil {
				return sourceSelection{}, fmt.Errorf("spotify fetch liked songs failed: %w", err)
			}
		}

		fmt.Println("Fetched Spotify playlists and liked songs.")
		return sourceSelection{Playlists: playlists, LikedSongs: liked, IncludeLiked: cfg.IncludeLiked}, nil
	}

	fmt.Println("Fetching Spotify playlists and liked songs...")
	lib, err := sp.FetchLibrary(ctx, cfg.LikedPlaylist)
	if err != nil {
		return sourceSelection{}, fmt.Errorf("spotify fetch failed: %w", err)
	}

	selectedPlaylists, includeLiked, err := ui.ResolveSelection(lib, cfg.AllPlaylists, cfg.IncludeLiked, cfg.NonInteractive, cfg.PlaylistNames)
	if err != nil {
		return sourceSelection{}, err
	}

	fmt.Println("Fetched Spotify playlists and liked songs.")
	return sourceSelection{Playlists: selectedPlaylists, LikedSongs: lib.LikedSongs, IncludeLiked: includeLiked}, nil
}

func runLoginCommand(ctx context.Context, cfg *config.Config, sess *session.Data) error {
	fmt.Println("QTransfer login setup")
	fmt.Println("This stores tokens/credentials in your session file for future runs.")

	scanner := bufio.NewScanner(os.Stdin)

	if strings.TrimSpace(cfg.SpotifyClientID) == "" {
		cfg.SpotifyClientID = prompt(scanner, "Spotify client ID", sess.Spotify.ClientID)
	}
	if strings.TrimSpace(cfg.SpotifyClientID) != "" {
		fmt.Println("Starting Spotify authentication...")
		tok, err := spotify.LoginWithPKCE(ctx, spotify.AuthConfig{
			ClientID:    cfg.SpotifyClientID,
			RedirectURI: cfg.SpotifyRedirect,
			Scopes:      cfg.SpotifyScopes,
			ManualCode:  cfg.SpotifyManual,
		})
		if err != nil {
			return fmt.Errorf("spotify login failed: %w", err)
		}
		updateSpotifySession(sess, tok, cfg.SpotifyClientID)
		fmt.Println("- Spotify login: OK")
	} else {
		fmt.Println("- Spotify login: skipped (no client id provided)")
	}

	if strings.TrimSpace(cfg.QobuzUsername) == "" {
		cfg.QobuzUsername = prompt(scanner, "Qobuz username/email", sess.Qobuz.Username)
	}
	if strings.TrimSpace(cfg.QobuzPassword) == "" {
		cfg.QobuzPassword = prompt(scanner, "Qobuz password", sess.Qobuz.Password)
	}

	if strings.TrimSpace(cfg.QobuzUsername) != "" && strings.TrimSpace(cfg.QobuzPassword) != "" {
		qb := qobuz.NewClient(cfg.QobuzAppID, cfg.QobuzAppSecret)
		if err := qb.Login(ctx, cfg.QobuzUsername, cfg.QobuzPassword); err != nil {
			return fmt.Errorf("qobuz login failed: %w", err)
		}
		if err := qb.VerifyAuth(ctx); err != nil {
			return fmt.Errorf("qobuz verify auth failed: %w", err)
		}
		sess.Qobuz.Username = cfg.QobuzUsername
		sess.Qobuz.Password = cfg.QobuzPassword
		sess.Qobuz.AccessToken = qb.Token()
		fmt.Println("- Qobuz login: OK")
	} else {
		fmt.Println("- Qobuz login: skipped (username/password not provided)")
	}

	if err := persistSession(*cfg, *sess); err != nil {
		return err
	}

	fmt.Printf("Login complete. Session saved to %s\n", session.ResolvePath(cfg.SessionPath))
	fmt.Println("You can now run `qtransfer` without passing username/password each time.")
	return nil
}

func runLogoutCommand(cfg config.Config) error {
	path := session.ResolvePath(cfg.SessionPath)
	if err := os.Remove(path); err != nil {
		if os.IsNotExist(err) {
			fmt.Printf("No session file found at %s\n", path)
			fmt.Println("Already logged out.")
			return nil
		}
		return fmt.Errorf("remove session file: %w", err)
	}
	fmt.Printf("Removed session file %s\n", path)
	fmt.Println("Logged out. Next run will require login again.")
	return nil
}

func prompt(scanner *bufio.Scanner, label, defaultValue string) string {
	defaultValue = strings.TrimSpace(defaultValue)
	if defaultValue != "" {
		fmt.Printf("%s [%s]: ", label, defaultValue)
	} else {
		fmt.Printf("%s: ", label)
	}
	if !scanner.Scan() {
		return defaultValue
	}
	v := strings.TrimSpace(scanner.Text())
	if v == "" {
		return defaultValue
	}
	return v
}

func runMonitorMode(ctx context.Context, cfg config.Config, sp *spotify.Client, sess *session.Data, selection sourceSelection, plans map[string]monitorPlan) error {
	if len(selection.Playlists) == 0 && !selection.IncludeLiked {
		return fmt.Errorf("monitor mode requires at least one playlist or --liked")
	}
	if sess.Playlists == nil {
		sess.Playlists = map[string]session.PlaylistSyncRef{}
	}

	fmt.Println("Starting monitor mode...")
	fmt.Printf("Watching %d playlist(s)", len(selection.Playlists))
	if selection.IncludeLiked {
		fmt.Printf(" + liked songs")
	}
	fmt.Printf(" | interval=%s", cfg.MonitorInterval)
	if cfg.MonitorTransfer {
		fmt.Printf(" | sync-mode=%s", cfg.SyncMode)
	}
	fmt.Println()

	playlistIDs := make([]string, 0, len(selection.Playlists))
	for _, p := range selection.Playlists {
		playlistIDs = append(playlistIDs, p.SourceID)
	}

	qb := (*qobuz.Client)(nil)
	matcher := (*match.Matcher)(nil)
	if cfg.MonitorTransfer {
		var err error
		qb, err = authenticateQobuz(ctx, cfg, sess)
		if err != nil {
			return err
		}
		matcher = match.NewMatcher(qb)
		fmt.Println("Monitor transfer mode enabled: changed playlists will be synced to Qobuz.")
	}

	prev := cloneMap(sess.Monitor)
	if len(prev) == 0 {
		baseline := buildFingerprintMap(selection.Playlists, selection.LikedSongs, selection.IncludeLiked)
		sess.Monitor = baseline
		if err := persistSession(cfg, *sess); err != nil {
			return err
		}
		fmt.Println("Initialized monitor baseline in session file.")
		prev = cloneMap(baseline)
	}

	runCycle := func() error {
		currentPlaylists, err := sp.FetchPlaylistsByID(ctx, playlistIDs)
		if err != nil {
			return fmt.Errorf("monitor fetch playlists failed: %w", err)
		}
		currentLiked := []model.Track{}
		if selection.IncludeLiked {
			currentLiked, err = sp.FetchLikedSongs(ctx)
			if err != nil {
				return fmt.Errorf("monitor fetch liked songs failed: %w", err)
			}
		}

		curr := buildFingerprintMap(currentPlaylists, currentLiked, selection.IncludeLiked)
		changedPlaylists, likedChanged := detectChanges(prev, curr, currentPlaylists, selection.IncludeLiked)
		if len(changedPlaylists) == 0 && !likedChanged {
			fmt.Printf("[%s] No updates detected.\n", time.Now().Format("15:04:05"))
			return nil
		}

		names := make([]string, 0, len(changedPlaylists))
		for _, p := range changedPlaylists {
			names = append(names, p.Name)
		}
		sort.Strings(names)
		if likedChanged {
			names = append(names, cfg.LikedPlaylist+" (liked)")
		}
		fmt.Printf("[%s] Updated: %s\n", time.Now().Format("15:04:05"), strings.Join(names, ", "))

		if cfg.MonitorTransfer && qb != nil && matcher != nil {
			for _, pl := range changedPlaylists {
				key := "playlist:" + pl.SourceID
				plan := plans[pl.SourceID]
				if err := syncChangedPlaylist(ctx, cfg, qb, matcher, key, pl, plan, sess); err != nil {
					fmt.Printf("Sync error for %s: %v\n", pl.Name, err)
					curr[key] = prev[key]
				}
			}
			if likedChanged {
				plan := monitorPlan{SyncMode: cfg.SyncMode}
				likedPlaylist := model.Playlist{Name: cfg.LikedPlaylist, Tracks: currentLiked}
				if err := syncChangedPlaylist(ctx, cfg, qb, matcher, "liked", likedPlaylist, plan, sess); err != nil {
					fmt.Printf("Sync error for liked songs: %v\n", err)
					curr["liked"] = prev["liked"]
				}
			}
			fmt.Print("\r")
			fmt.Println("Monitor sync cycle complete.                                                             ")
		}

		prev = curr
		sess.Monitor = cloneMap(curr)
		return persistSession(cfg, *sess)
	}

	if err := runCycle(); err != nil {
		return err
	}
	if cfg.MonitorOnce {
		fmt.Println("Monitor once completed.")
		return nil
	}

	ticker := time.NewTicker(cfg.MonitorInterval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Println("Monitor stopped.")
			return nil
		case <-ticker.C:
			if err := runCycle(); err != nil {
				fmt.Printf("Monitor cycle error: %v\n", err)
			}
		}
	}
}

func authenticateSpotify(ctx context.Context, cfg config.Config, sess *session.Data) (spotify.Token, error) {
	if cfg.RememberCreds && strings.TrimSpace(sess.Spotify.AccessToken) != "" && time.Now().Before(sess.Spotify.ExpiresAt.Add(-30*time.Second)) {
		return spotify.Token{
			AccessToken:  sess.Spotify.AccessToken,
			RefreshToken: sess.Spotify.RefreshToken,
			Scope:        sess.Spotify.Scope,
		}, nil
	}

	if strings.TrimSpace(cfg.SpotifyClientID) == "" {
		return spotify.Token{}, fmt.Errorf("spotify client id required (set --spotify-client-id once or run `qtransfer login`)")
	}

	if cfg.RememberCreds && strings.TrimSpace(sess.Spotify.RefreshToken) != "" {
		fmt.Println("Refreshing Spotify access token from session...")
		tok, err := spotify.RefreshAccessToken(ctx, cfg.SpotifyClientID, sess.Spotify.RefreshToken)
		if err == nil {
			updateSpotifySession(sess, tok, cfg.SpotifyClientID)
			return tok, nil
		}
		fmt.Printf("Spotify token refresh failed, falling back to login: %v\n", err)
	}

	fmt.Println("Starting Spotify authentication...")
	tok, err := spotify.LoginWithPKCE(ctx, spotify.AuthConfig{
		ClientID:    cfg.SpotifyClientID,
		RedirectURI: cfg.SpotifyRedirect,
		Scopes:      cfg.SpotifyScopes,
		ManualCode:  cfg.SpotifyManual,
	})
	if err != nil {
		return spotify.Token{}, err
	}
	if cfg.RememberCreds {
		updateSpotifySession(sess, tok, cfg.SpotifyClientID)
	}
	return tok, nil
}

func authenticateQobuz(ctx context.Context, cfg config.Config, sess *session.Data) (*qobuz.Client, error) {
	qb := qobuz.NewClient(cfg.QobuzAppID, cfg.QobuzAppSecret)

	if cfg.RememberCreds && strings.TrimSpace(sess.Qobuz.AccessToken) != "" {
		qb.SetToken(sess.Qobuz.AccessToken)
		if err := qb.VerifyAuth(ctx); err == nil {
			return qb, nil
		}
	}

	if strings.TrimSpace(cfg.QobuzUsername) == "" || strings.TrimSpace(cfg.QobuzPassword) == "" {
		return nil, fmt.Errorf("qobuz username/password required (pass flags once or enable --remember-creds with existing session)")
	}

	fmt.Println("Authenticating with Qobuz...")
	if err := qb.Login(ctx, cfg.QobuzUsername, cfg.QobuzPassword); err != nil {
		return nil, fmt.Errorf("qobuz login failed: %w", err)
	}
	if err := qb.VerifyAuth(ctx); err != nil {
		return nil, fmt.Errorf("qobuz auth verification failed: %w", err)
	}

	if cfg.RememberCreds {
		sess.Qobuz.Username = cfg.QobuzUsername
		sess.Qobuz.Password = cfg.QobuzPassword
		sess.Qobuz.AccessToken = qb.Token()
	}

	return qb, nil
}

func runQobuzSelfTest(ctx context.Context, cfg config.Config, sess *session.Data) error {
	fmt.Println("Running Qobuz self-test...")
	qb, err := authenticateQobuz(ctx, cfg, sess)
	if err != nil {
		return err
	}
	fmt.Println("- Login: OK")

	if err := qb.VerifyAuth(ctx); err != nil {
		return fmt.Errorf("qobuz verify auth failed: %w", err)
	}
	fmt.Println("- user/get auth check: OK")

	results, err := qb.SearchTracks(ctx, cfg.QobuzTestQuery, 5)
	if err != nil {
		return fmt.Errorf("qobuz search failed: %w", err)
	}
	fmt.Printf("- Search '%s': %d result(s)\n", cfg.QobuzTestQuery, len(results))

	if cfg.QobuzTestWrite {
		name := fmt.Sprintf("QTransfer SelfTest %d", time.Now().Unix())
		playlistID, err := qb.CreatePlaylist(ctx, name, "temporary playlist created by qtransfer self-test", false)
		if err != nil {
			return fmt.Errorf("qobuz create playlist failed: %w", err)
		}
		fmt.Printf("- Create playlist: OK (id=%d)\n", playlistID)

		if len(results) > 0 {
			if err := qb.AddTracksToPlaylist(ctx, playlistID, []int64{results[0].ID}); err != nil {
				if delErr := qb.DeletePlaylist(context.Background(), playlistID); delErr != nil {
					fmt.Printf("- Cleanup test playlist after add failure: failed (%v)\n", delErr)
				}
				return fmt.Errorf("qobuz add tracks failed: %w", err)
			}
			fmt.Println("- Add first search result to playlist: OK")
		} else {
			fmt.Println("- Add first search result: skipped (no search results)")
		}

		if err := qb.DeletePlaylist(ctx, playlistID); err != nil {
			fmt.Printf("- Cleanup test playlist: failed (%v)\n", err)
		} else {
			fmt.Println("- Cleanup test playlist: OK")
		}
	}

	fmt.Println("Qobuz self-test passed.")
	return nil
}

func resolvePlaylistIDs(inputs []string) ([]string, error) {
	seen := map[string]struct{}{}
	out := make([]string, 0, len(inputs))
	for _, in := range inputs {
		id, err := spotify.ParsePlaylistID(in)
		if err != nil {
			return nil, fmt.Errorf("invalid playlist-url %q: %w", in, err)
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("no playlist URLs provided")
	}
	return out, nil
}

func applyFileGlobals(cfg *config.Config, g jobconfig.GlobalConfig) error {
	if g.Monitor != nil {
		cfg.Monitor = *g.Monitor
	}
	if g.MonitorOnce != nil {
		cfg.MonitorOnce = *g.MonitorOnce
	}
	if g.MonitorTransfer != nil {
		cfg.MonitorTransfer = *g.MonitorTransfer
	}
	if strings.TrimSpace(g.MonitorInterval) != "" {
		d, err := time.ParseDuration(strings.TrimSpace(g.MonitorInterval))
		if err != nil {
			return fmt.Errorf("invalid global monitor_interval %q: %w", g.MonitorInterval, err)
		}
		cfg.MonitorInterval = d
	}
	if strings.TrimSpace(g.SyncMode) != "" {
		cfg.SyncMode = strings.ToLower(strings.TrimSpace(g.SyncMode))
	}
	if g.IncludeLiked != nil {
		cfg.IncludeLiked = *g.IncludeLiked
	}
	if g.DryRun != nil {
		cfg.DryRun = *g.DryRun
	}
	if g.PublicPlaylists != nil {
		cfg.PublicPlaylists = *g.PublicPlaylists
	}
	if g.Concurrency != nil {
		cfg.Concurrency = *g.Concurrency
	}
	if strings.TrimSpace(g.ReportPath) != "" {
		cfg.ReportPath = strings.TrimSpace(g.ReportPath)
	}

	return cfg.Validate()
}

func buildPlaylistPlans(cfg config.Config, fileEntries []jobconfig.PlaylistEntry) ([]string, map[string]monitorPlan, error) {
	urls := make([]string, 0, len(cfg.PlaylistURLs)+len(fileEntries))
	for _, u := range cfg.PlaylistURLs {
		u = strings.TrimSpace(u)
		if u != "" {
			urls = append(urls, u)
		}
	}

	plans := map[string]monitorPlan{}

	for _, raw := range urls {
		id, err := spotify.ParsePlaylistID(raw)
		if err != nil {
			return nil, nil, fmt.Errorf("invalid playlist-url %q: %w", raw, err)
		}
		plans[id] = monitorPlan{SyncMode: cfg.SyncMode, TargetPlaylistID: cfg.TargetPlaylistID}
	}

	for i, entry := range fileEntries {
		if !entry.IsEnabled() {
			continue
		}
		raw := strings.TrimSpace(entry.URL)
		id, err := spotify.ParsePlaylistID(raw)
		if err != nil {
			return nil, nil, fmt.Errorf("invalid config playlist entry %d url %q: %w", i+1, entry.URL, err)
		}
		mode := strings.ToLower(strings.TrimSpace(entry.SyncMode))
		if mode == "" {
			mode = cfg.SyncMode
		}
		if mode == "" {
			mode = "append"
		}
		if entry.TargetPlaylistID > 0 && mode == "mirror" {
			return nil, nil, fmt.Errorf("playlist entry %d cannot use mirror mode with target_playlist_id", i+1)
		}
		plans[id] = monitorPlan{SyncMode: mode, TargetPlaylistID: entry.TargetPlaylistID}
		urls = append(urls, raw)
	}

	if cfg.TargetPlaylistID > 0 {
		// Apply the global target to all plans that don't already have a per-playlist target.
		for id, plan := range plans {
			if plan.TargetPlaylistID == 0 {
				plan.TargetPlaylistID = cfg.TargetPlaylistID
				plans[id] = plan
			}
		}
	}

	seen := map[string]struct{}{}
	deduped := make([]string, 0, len(urls))
	for _, u := range urls {
		u = strings.TrimSpace(u)
		if u == "" {
			continue
		}
		if _, ok := seen[u]; ok {
			continue
		}
		seen[u] = struct{}{}
		deduped = append(deduped, u)
	}

	return deduped, plans, nil
}

func buildFingerprintMap(playlists []model.Playlist, liked []model.Track, includeLiked bool) map[string]string {
	m := make(map[string]string, len(playlists)+1)
	for _, p := range playlists {
		if strings.TrimSpace(p.SourceID) == "" {
			continue
		}
		m["playlist:"+p.SourceID] = playlistFingerprint(p)
	}
	if includeLiked {
		m["liked"] = trackListFingerprint(liked)
	}
	return m
}

func detectChanges(prev, curr map[string]string, playlists []model.Playlist, includeLiked bool) ([]model.Playlist, bool) {
	changed := []model.Playlist{}
	playlistByID := map[string]model.Playlist{}
	for _, p := range playlists {
		playlistByID[p.SourceID] = p
	}

	for key, now := range curr {
		if prev[key] == now {
			continue
		}
		if strings.HasPrefix(key, "playlist:") {
			id := strings.TrimPrefix(key, "playlist:")
			if p, ok := playlistByID[id]; ok {
				changed = append(changed, p)
			}
		}
	}

	likedChanged := false
	if includeLiked && prev["liked"] != curr["liked"] {
		likedChanged = true
	}

	return changed, likedChanged
}

func playlistFingerprint(pl model.Playlist) string {
	h := sha1.New()
	h.Write([]byte(pl.SourceID))
	h.Write([]byte("|"))
	h.Write([]byte(pl.Name))
	h.Write([]byte("|"))
	h.Write([]byte(trackListFingerprint(pl.Tracks)))
	return hex.EncodeToString(h.Sum(nil))
}

func trackListFingerprint(tracks []model.Track) string {
	h := sha1.New()
	for _, t := range tracks {
		id := strings.TrimSpace(t.SourceID)
		if id == "" {
			id = strings.ToLower(strings.TrimSpace(t.Title + "|" + strings.Join(t.Artists, ",") + "|" + t.Album))
		}
		h.Write([]byte(id))
		h.Write([]byte("\n"))
	}
	return hex.EncodeToString(h.Sum(nil))
}

func applySessionDefaults(cfg *config.Config, sess *session.Data) {
	if strings.TrimSpace(cfg.SpotifyClientID) == "" {
		cfg.SpotifyClientID = sess.Spotify.ClientID
	}
	if strings.TrimSpace(cfg.QobuzUsername) == "" {
		cfg.QobuzUsername = sess.Qobuz.Username
	}
	if strings.TrimSpace(cfg.QobuzPassword) == "" {
		cfg.QobuzPassword = sess.Qobuz.Password
	}
}

func updateSpotifySession(sess *session.Data, tok spotify.Token, clientID string) {
	if strings.TrimSpace(clientID) != "" {
		sess.Spotify.ClientID = strings.TrimSpace(clientID)
	}
	sess.Spotify.AccessToken = tok.AccessToken
	if tok.RefreshToken != "" {
		sess.Spotify.RefreshToken = tok.RefreshToken
	}
	sess.Spotify.Scope = tok.Scope
	if tok.ExpiresIn > 0 {
		sess.Spotify.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
	}
}

func persistSession(cfg config.Config, sess session.Data) error {
	if !cfg.RememberCreds {
		sess.Spotify = session.SpotifyState{}
		sess.Qobuz = session.QobuzState{}
	}
	if sess.Monitor == nil {
		sess.Monitor = map[string]string{}
	}
	if sess.Playlists == nil {
		sess.Playlists = map[string]session.PlaylistSyncRef{}
	}
	return session.Save(cfg.SessionPath, sess)
}

func cloneMap(in map[string]string) map[string]string {
	out := map[string]string{}
	for k, v := range in {
		out[k] = v
	}
	return out
}

func syncChangedPlaylist(ctx context.Context, cfg config.Config, qb *qobuz.Client, matcher *match.Matcher, key string, pl model.Playlist, plan monitorPlan, sess *session.Data) error {
	mode := strings.ToLower(strings.TrimSpace(plan.SyncMode))
	if mode == "" {
		mode = cfg.SyncMode
	}
	if mode == "" {
		mode = "append"
	}
	if plan.TargetPlaylistID > 0 && mode == "mirror" {
		return fmt.Errorf("mirror mode is not supported with explicit target playlist id (%d)", plan.TargetPlaylistID)
	}

	prev := sess.Playlists[key]
	matchedIDs, unmatched := matchPlaylistTracks(ctx, matcher, pl.Tracks, cfg.Concurrency, func(done, total int) {
		fmt.Printf("\r%-140s", fmt.Sprintf("Matching %s (%d/%d)", pl.Name, done, total))
	})
	matchedIDs = uniqueIDs(matchedIDs)
	fmt.Print("\r")

	fingerprint := playlistFingerprint(pl)
	if key == "liked" {
		fingerprint = trackListFingerprint(pl.Tracks)
	}

	if cfg.DryRun {
		fmt.Printf("Dry-run sync %s: matched=%d/%d unmatched=%d\n", pl.Name, len(matchedIDs), len(pl.Tracks), len(unmatched))
		sess.Playlists[key] = session.PlaylistSyncRef{
			SourceName:      pl.Name,
			QobuzPlaylistID: prev.QobuzPlaylistID,
			Fingerprint:     fingerprint,
		}
		return nil
	}

	targetID := prev.QobuzPlaylistID
	if plan.TargetPlaylistID > 0 {
		targetID = plan.TargetPlaylistID
	}
	if mode == "mirror" && targetID > 0 {
		if err := qb.DeletePlaylist(ctx, targetID); err != nil {
			fmt.Printf("Warning: failed deleting old playlist %d for mirror sync: %v\n", targetID, err)
		}
		targetID = 0
	}

	if targetID == 0 {
		createdID, err := qb.CreatePlaylist(ctx, pl.Name, sanitizeDescription(pl.Description), cfg.PublicPlaylists)
		if err != nil {
			return fmt.Errorf("create qobuz playlist: %w", err)
		}
		targetID = createdID
	}

	if len(matchedIDs) > 0 {
		if err := qb.AddTracksToPlaylist(ctx, targetID, matchedIDs); err != nil {
			if errors.Is(err, qobuz.ErrDuplicateTracks) {
				fmt.Printf("No new tracks added for %s: matched tracks already exist in Qobuz playlist %d.\n", pl.Name, targetID)
			} else {
				return fmt.Errorf("add tracks to qobuz playlist %d: %w", targetID, err)
			}
		}
	}

	sess.Playlists[key] = session.PlaylistSyncRef{
		SourceName:      pl.Name,
		QobuzPlaylistID: targetID,
		Fingerprint:     fingerprint,
	}

	fmt.Printf("Synced %s (%s): qobuz=%d matched=%d/%d unmatched=%d\n", pl.Name, mode, targetID, len(matchedIDs), len(pl.Tracks), len(unmatched))
	return nil
}

func matchPlaylistTracks(ctx context.Context, matcher *match.Matcher, tracks []model.Track, concurrency int, progress func(done, total int)) ([]int64, []model.MatchedTrack) {
	if concurrency < 1 {
		concurrency = 1
	}

	type job struct {
		idx int
		trk model.Track
	}
	type out struct {
		idx int
		res model.MatchedTrack
	}

	jobs := make(chan job)
	results := make(chan out)

	var wg sync.WaitGroup
	for i := 0; i < concurrency; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobs {
				m := matcher.MatchTrack(ctx, j.trk)
				results <- out{idx: j.idx, res: m}
			}
		}()
	}

	go func() {
		for i, t := range tracks {
			jobs <- job{idx: i, trk: t}
		}
		close(jobs)
		wg.Wait()
		close(results)
	}()

	ordered := make([]model.MatchedTrack, len(tracks))
	total := len(tracks)
	step := 1
	if total > 100 {
		step = total / 100
	}
	done := 0
	for r := range results {
		ordered[r.idx] = r.res
		done++
		if progress != nil && (done == 1 || done == total || done%step == 0) {
			progress(done, total)
		}
	}

	matched := make([]int64, 0, len(tracks))
	unmatched := make([]model.MatchedTrack, 0)
	for _, r := range ordered {
		if r.Matched && r.QobuzID > 0 {
			matched = append(matched, r.QobuzID)
		} else {
			unmatched = append(unmatched, r)
		}
	}
	return matched, unmatched
}

func uniqueIDs(ids []int64) []int64 {
	seen := map[int64]struct{}{}
	out := make([]int64, 0, len(ids))
	for _, id := range ids {
		if id == 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func sanitizeDescription(s string) string {
	s = strings.TrimSpace(s)
	if len(s) <= 1000 {
		return s
	}
	return s[:1000]
}

func printSummary(rep model.TransferReport, reportPath string, elapsed time.Duration, dryRun bool) {
	mode := "TRANSFER"
	if dryRun {
		mode = "DRY-RUN"
	}

	fmt.Printf("\n%s complete in %s\n", mode, elapsed.Round(time.Second))
	totalMatched := 0
	totalAdded := 0
	totalUnmatched := 0
	totalErrors := 0

	for _, r := range rep.Results {
		unmatched := len(r.Unmatched)
		totalMatched += r.MatchedTracks
		totalAdded += r.AddedTracks
		totalUnmatched += unmatched
		totalErrors += len(r.Errors)

		targetInfo := ""
		if r.TargetID > 0 {
			targetInfo = fmt.Sprintf(" -> Qobuz %d", r.TargetID)
		}
		fmt.Printf("- %s%s: %d total, %d matched, %d added, %d unmatched\n", r.Name, targetInfo, r.TotalTracks, r.MatchedTracks, r.AddedTracks, unmatched)
		if len(r.Errors) > 0 {
			fmt.Printf("  errors: %s\n", strings.Join(r.Errors, " | "))
		}
	}

	fmt.Printf("\nTotals: matched=%d added=%d unmatched=%d errors=%d\n", totalMatched, totalAdded, totalUnmatched, totalErrors)
	fmt.Printf("Report written to %s\n", reportPath)
}
