// Package cmd implements CLI subcommands for cliamp.
package cmd

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"

	"github.com/bjarneo/cliamp/external/local"
	"github.com/bjarneo/cliamp/internal/sshurl"
	"github.com/bjarneo/cliamp/player"
	"github.com/bjarneo/cliamp/playlist"
	"github.com/bjarneo/cliamp/resolve"
)

// PlaylistList prints all playlists with their track counts.
func PlaylistList() error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	lists, err := prov.Playlists()
	if err != nil {
		return fmt.Errorf("listing playlists: %w", err)
	}
	if len(lists) == 0 {
		fmt.Println("No playlists found.")
		return nil
	}

	maxName := 0
	for _, pl := range lists {
		if len(pl.Name) > maxName {
			maxName = len(pl.Name)
		}
	}
	for _, pl := range lists {
		fmt.Printf("  %-*s  %d tracks\n", maxName, pl.Name, pl.TrackCount)
	}
	return nil
}

// PlaylistCreate creates a new playlist from the given file and directory paths.
// If sshHost is non-empty, remote paths are walked via SSH.
func PlaylistCreate(name string, paths []string, sshHost string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	if prov.Exists(name) {
		return fmt.Errorf("playlist %q already exists (use `add` to append)", name)
	}
	if len(paths) == 0 && sshHost == "" {
		if _, err := prov.CreatePlaylist(context.Background(), name); err != nil {
			return fmt.Errorf("creating playlist: %w", err)
		}
		fmt.Printf("Created empty playlist %q.\n", name)
		return nil
	}

	var audioPaths []string
	if sshHost != "" {
		remotePaths, err := sshFindAudio(sshHost, paths)
		if err != nil {
			return err
		}
		audioPaths = remotePaths
	} else {
		collected, err := collectLocalAudio(paths)
		if err != nil {
			return err
		}
		audioPaths = collected
	}

	if len(audioPaths) == 0 {
		return fmt.Errorf("no audio files found in %s", strings.Join(paths, ", "))
	}

	tracks := make([]playlist.Track, len(audioPaths))
	for i, ap := range audioPaths {
		if sshHost != "" {
			tracks[i] = playlist.TrackFromFilename(ap)
			tracks[i].Path = "ssh://" + sshHost + ap
		} else {
			tracks[i] = playlist.TrackFromPath(ap)
		}
	}

	albumAwareSort(tracks)
	added, skipped, err := prov.AddTracks(name, tracks)
	if err != nil {
		return fmt.Errorf("writing playlist: %w", err)
	}

	if skipped > 0 {
		fmt.Printf("Created playlist %q with %d tracks (%d duplicate skipped).\n", name, added, skipped)
	} else {
		fmt.Printf("Created playlist %q with %d tracks.\n", name, added)
	}
	return nil
}

// PlaylistAdd appends tracks from the given paths to an existing playlist.
func PlaylistAdd(name string, paths []string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	if !prov.Exists(name) {
		return fmt.Errorf("playlist %q not found", name)
	}

	audioPaths, err := collectLocalAudio(paths)
	if err != nil {
		return err
	}
	if len(audioPaths) == 0 {
		return fmt.Errorf("no audio files found in %s", strings.Join(paths, ", "))
	}

	tracks := make([]playlist.Track, len(audioPaths))
	for i, ap := range audioPaths {
		tracks[i] = playlist.TrackFromPath(ap)
	}

	albumAwareSort(tracks)
	added, skipped, err := prov.AddTracks(name, tracks)
	if err != nil {
		return fmt.Errorf("adding tracks: %w", err)
	}

	if skipped > 0 {
		fmt.Printf("Added %d tracks to %q (%d duplicate skipped).\n", added, name, skipped)
	} else {
		fmt.Printf("Added %d tracks to %q.\n", added, name)
	}
	return nil
}

// PlaylistShow displays the tracks in a playlist. If jsonOutput is true,
// the track list is printed as a JSON array to stdout.
func PlaylistShow(name string, jsonOutput bool) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	tracks, err := prov.Tracks(name)
	if err != nil {
		return fmt.Errorf("playlist %q not found", name)
	}
	if len(tracks) == 0 {
		if jsonOutput {
			fmt.Println("[]")
		} else {
			fmt.Printf("Playlist %q is empty.\n", name)
		}
		return nil
	}

	if jsonOutput {
		type jsonTrack struct {
			Path         string `json:"path"`
			Title        string `json:"title"`
			Artist       string `json:"artist,omitempty"`
			Album        string `json:"album,omitempty"`
			Genre        string `json:"genre,omitempty"`
			Year         int    `json:"year,omitempty"`
			TrackNumber  int    `json:"track_number,omitempty"`
			DurationSecs int    `json:"duration_secs,omitempty"`
			AlbumArtURL  string `json:"album_art_url,omitempty"`
			Bookmark     bool   `json:"bookmark,omitempty"`
		}
		out := make([]jsonTrack, len(tracks))
		for i, t := range tracks {
			out[i] = jsonTrack{
				Path:         t.Path,
				Title:        t.Title,
				Artist:       t.Artist,
				Album:        t.Album,
				Genre:        t.Genre,
				Year:         t.Year,
				TrackNumber:  t.TrackNumber,
				DurationSecs: t.DurationSecs,
				AlbumArtURL:  t.AlbumArtURL,
				Bookmark:     t.Bookmark,
			}
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(out)
	}

	fmt.Printf("Playlist: %s (%d tracks)\n\n", name, len(tracks))
	for i, t := range tracks {
		display := t.Title
		if t.Artist != "" {
			display = t.Artist + " - " + t.Title
		}
		fmt.Printf("  %3d. %s\n", i+1, display)
	}
	return nil
}

// PlaylistRemove removes a track by index from the named playlist.
// The index is 1-based for the user, converted to 0-based internally.
func PlaylistRemove(name string, index int) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	if err := prov.RemoveTrack(name, index-1); err != nil {
		return fmt.Errorf("removing track %d from %q: %w", index, name, err)
	}

	fmt.Printf("Removed track %d from %q.\n", index, name)
	return nil
}

// PlaylistDelete deletes an entire playlist.
func PlaylistDelete(name string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	if err := prov.DeletePlaylist(name); err != nil {
		return fmt.Errorf("deleting playlist %q: %w", name, err)
	}

	fmt.Printf("Deleted playlist %q.\n", name)
	return nil
}

// PlaylistRename renames a local playlist.
func PlaylistRename(oldName, newName string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}
	if err := prov.RenamePlaylist(oldName, newName); err != nil {
		return fmt.Errorf("renaming playlist %q to %q: %w", oldName, newName, err)
	}
	fmt.Printf("Renamed playlist %q to %q.\n", oldName, newName)
	return nil
}

// PlaylistDedupe removes duplicate tracks by exact path, keeping first wins.
func PlaylistDedupe(name string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}
	tracks, err := prov.Tracks(name)
	if err != nil {
		return fmt.Errorf("loading playlist %q: %w", name, err)
	}
	seen := make(map[string]struct{}, len(tracks))
	kept := tracks[:0]
	removed := 0
	for _, t := range tracks {
		if _, ok := seen[t.Path]; ok {
			removed++
			fmt.Printf("  removed duplicate: %s\n", t.Path)
			continue
		}
		seen[t.Path] = struct{}{}
		kept = append(kept, t)
	}
	if removed == 0 {
		fmt.Printf("No duplicates found in %q.\n", name)
		return nil
	}
	if err := prov.SavePlaylist(name, kept); err != nil {
		return fmt.Errorf("saving playlist %q: %w", name, err)
	}
	fmt.Printf("Removed %d duplicate tracks from %q.\n", removed, name)
	return nil
}

// PlaylistSort sorts a playlist in place by one of the supported metadata keys.
func PlaylistSort(name, by string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}
	tracks, err := prov.Tracks(name)
	if err != nil {
		return fmt.Errorf("loading playlist %q: %w", name, err)
	}
	if err := sortTracks(tracks, by); err != nil {
		return err
	}
	if err := prov.SavePlaylist(name, tracks); err != nil {
		return fmt.Errorf("saving playlist %q: %w", name, err)
	}
	fmt.Printf("Sorted %q by %s.\n", name, normalizeSortKey(by))
	return nil
}

// PlaylistDoctor reports missing local files and optionally prunes them.
func PlaylistDoctor(name string, fix bool) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}
	names := []string{name}
	if name == "" {
		lists, err := prov.Playlists()
		if err != nil {
			return fmt.Errorf("listing playlists: %w", err)
		}
		names = names[:0]
		for _, pl := range lists {
			if pl.Name != "Recently Played" {
				names = append(names, pl.Name)
			}
		}
	}

	totalMissing := 0
	for _, plName := range names {
		tracks, err := prov.Tracks(plName)
		if err != nil {
			return fmt.Errorf("loading playlist %q: %w", plName, err)
		}
		kept := tracks[:0]
		missing := 0
		for _, t := range tracks {
			if missingLocalFile(t) {
				missing++
				totalMissing++
				fmt.Printf("  [%s] missing: %s\n", plName, t.Path)
				if fix {
					continue
				}
			}
			kept = append(kept, t)
		}
		if fix && missing > 0 {
			if err := prov.SavePlaylist(plName, kept); err != nil {
				return fmt.Errorf("saving playlist %q: %w", plName, err)
			}
			fmt.Printf("Pruned %d missing tracks from %q.\n", missing, plName)
		}
	}
	if totalMissing == 0 {
		fmt.Println("No missing local files found.")
	}
	return nil
}

// PlaylistExport writes a playlist as M3U or PLS.
func PlaylistExport(name, format, output string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}
	tracks, err := prov.Tracks(name)
	if err != nil {
		return fmt.Errorf("loading playlist %q: %w", name, err)
	}
	format = strings.ToLower(strings.TrimSpace(format))
	if format == "" {
		format = "m3u"
	}
	switch format {
	case "m3u", "m3u8", "pls":
	default:
		return fmt.Errorf("unsupported export format %q (use m3u or pls)", format)
	}

	var w io.Writer = os.Stdout
	var f *os.File
	if output != "" {
		f, err = os.Create(output)
		if err != nil {
			return fmt.Errorf("creating %q: %w", output, err)
		}
		defer f.Close()
		w = f
	}

	switch format {
	case "m3u", "m3u8":
		writeM3U(w, tracks)
	case "pls":
		writePLS(w, tracks)
	}
	if output != "" {
		fmt.Printf("Exported %q to %s.\n", name, output)
	}
	return nil
}

// PlaylistImport converts a local M3U/PLS file into a TOML playlist.
func PlaylistImport(path, name string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}
	if name == "" {
		base := filepath.Base(path)
		name = strings.TrimSuffix(base, filepath.Ext(base))
	}
	if prov.Exists(name) {
		return fmt.Errorf("playlist %q already exists", name)
	}
	tracks, err := resolve.LocalPlaylist(path)
	if err != nil {
		return fmt.Errorf("importing %q: %w", path, err)
	}
	if err := prov.SavePlaylist(name, tracks); err != nil {
		return fmt.Errorf("saving playlist %q: %w", name, err)
	}
	fmt.Printf("Imported %d tracks into %q.\n", len(tracks), name)
	return nil
}

// PlaylistBookmark toggles the bookmark flag on a track by index.
func PlaylistBookmark(name string, index int) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	if err := prov.SetBookmark(name, index-1); err != nil {
		return fmt.Errorf("toggling bookmark: %w", err)
	}

	tracks, err := prov.Tracks(name)
	if err != nil {
		return err
	}
	if index-1 < 0 || index-1 >= len(tracks) {
		return fmt.Errorf("track %d no longer exists in playlist (now has %d tracks)", index, len(tracks))
	}
	t := tracks[index-1]
	if t.Bookmark {
		fmt.Printf("★ %s\n", t.DisplayName())
	} else {
		fmt.Printf("☆ %s\n", t.DisplayName())
	}
	return nil
}

// PlaylistBookmarks lists all bookmarked tracks across all playlists.
func PlaylistBookmarks() error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	lists, err := prov.Playlists()
	if err != nil {
		return fmt.Errorf("listing playlists: %w", err)
	}

	total := 0
	for _, pl := range lists {
		tracks, err := prov.Tracks(pl.Name)
		if err != nil {
			continue
		}
		for i, t := range tracks {
			if t.Bookmark {
				fmt.Printf("  ★ [%s] %d. %s\n", pl.Name, i+1, t.DisplayName())
				total++
			}
		}
	}

	if total == 0 {
		fmt.Println("No bookmarks yet. Press f on a track to bookmark it.")
	} else {
		fmt.Printf("\n  %d bookmarks across %d playlists.\n", total, len(lists))
	}
	return nil
}

// PlaylistEnrich probes duration and derives album metadata for SSH tracks.
func PlaylistEnrich(name string) error {
	prov, err := newProvider()
	if err != nil {
		return err
	}

	tracks, err := prov.Tracks(name)
	if err != nil {
		return fmt.Errorf("loading playlist %q: %w", name, err)
	}

	updated := 0
	for i, t := range tracks {
		changed := false

		if t.DurationSecs == 0 {
			dur := probeDuration(t.Path)
			if dur > 0 {
				tracks[i].DurationSecs = dur
				changed = true
				fmt.Fprintf(os.Stderr, "  %s: %ds\n", t.DisplayName(), dur)
			}
		}

		if t.Album == "" {
			if dir := albumFromPath(t.Path); dir != "" {
				tracks[i].Album = dir
				changed = true
			}
		}

		if changed {
			updated++
		}
	}

	if updated == 0 {
		fmt.Println("All tracks already enriched.")
		return nil
	}

	if err := prov.SavePlaylist(name, tracks); err != nil {
		return fmt.Errorf("saving playlist %q: %w", name, err)
	}

	fmt.Printf("Enriched %d tracks in %q.\n", updated, name)
	return nil
}

func probeRemoteDuration(host, remotePath string) int {
	// Use ffprobe over SSH for cross-platform compatibility (works on Linux and macOS remotes).
	probeCmd := fmt.Sprintf("ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 %s 2>/dev/null", shellQuote(remotePath))
	cmd := exec.Command("ssh",
		"-o", "BatchMode=yes",
		"-o", "StrictHostKeyChecking=yes",
		"-o", "ConnectTimeout=5",
		host, probeCmd,
	)
	out, err := cmd.Output()
	if err != nil {
		return 0
	}
	return parseProbeDuration(out)
}

func probeDuration(path string) int {
	if strings.HasPrefix(path, "ssh://") {
		parsed, err := sshurl.Parse(path)
		if err != nil {
			return 0
		}
		return probeRemoteDuration(parsed.Host, parsed.Path)
	}
	if playlist.IsURL(path) || path == "" {
		return 0
	}
	cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path)
	out, err := cmd.Output()
	if err != nil {
		return 0
	}
	return parseProbeDuration(out)
}

func parseProbeDuration(out []byte) int {
	s := strings.TrimSpace(string(out))
	if s == "" {
		return 0
	}
	var dur float64
	fmt.Sscanf(s, "%f", &dur)
	if dur <= 0 {
		return 0
	}
	return int(dur)
}

func albumFromPath(path string) string {
	if path == "" || playlist.IsURL(path) {
		return ""
	}
	if strings.HasPrefix(path, "ssh://") {
		parsed, err := sshurl.Parse(path)
		if err != nil {
			return ""
		}
		path = parsed.Path
	}
	dir := filepath.Base(filepath.Dir(path))
	if dir == "." || dir == string(filepath.Separator) {
		return ""
	}
	return dir
}

// collectLocalAudio resolves file/directory paths into audio file paths
// using the canonical supported extensions from the player package.
func collectLocalAudio(paths []string) ([]string, error) {
	var all []string
	for _, p := range paths {
		files, err := resolve.CollectAudioFiles(p)
		if err != nil {
			return nil, fmt.Errorf("scanning %q: %w", p, err)
		}
		all = append(all, files...)
	}
	return all, nil
}

func albumAwareSort(tracks []playlist.Track) {
	if len(tracks) < 2 {
		return
	}
	for _, t := range tracks {
		if t.Album == "" || t.TrackNumber == 0 {
			return
		}
	}
	sort.SliceStable(tracks, func(i, j int) bool {
		a, b := tracks[i], tracks[j]
		if c := strings.Compare(strings.ToLower(a.Artist), strings.ToLower(b.Artist)); c != 0 {
			return c < 0
		}
		if c := strings.Compare(strings.ToLower(a.Album), strings.ToLower(b.Album)); c != 0 {
			return c < 0
		}
		if a.TrackNumber != b.TrackNumber {
			return a.TrackNumber < b.TrackNumber
		}
		return strings.ToLower(a.Path) < strings.ToLower(b.Path)
	})
}

func normalizeSortKey(by string) string {
	switch strings.ToLower(strings.TrimSpace(by)) {
	case "", "title":
		return "title"
	case "track", "track#", "track_number", "track-number":
		return "track"
	case "artist":
		return "artist"
	case "album":
		return "album"
	case "artist+album", "artist_album", "artist-album":
		return "artist+album"
	case "path":
		return "path"
	default:
		return by
	}
}

func sortTracks(tracks []playlist.Track, by string) error {
	key := normalizeSortKey(by)
	switch key {
	case "title", "track", "artist", "album", "artist+album", "path":
	default:
		return fmt.Errorf("unsupported sort key %q (use track, title, artist, album, artist+album, or path)", by)
	}
	sort.SliceStable(tracks, func(i, j int) bool {
		return compareTracks(tracks[i], tracks[j], key) < 0
	})
	return nil
}

func compareTracks(a, b playlist.Track, key string) int {
	cmpString := func(x, y string) int {
		return strings.Compare(strings.ToLower(x), strings.ToLower(y))
	}
	firstNonZero := func(values ...int) int {
		for _, v := range values {
			if v != 0 {
				return v
			}
		}
		return 0
	}
	switch key {
	case "track":
		return firstNonZero(a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
	case "artist":
		return firstNonZero(cmpString(a.Artist, b.Artist), cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
	case "album":
		return firstNonZero(cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
	case "artist+album":
		return firstNonZero(cmpString(a.Artist, b.Artist), cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
	case "path":
		return cmpString(a.Path, b.Path)
	default:
		return firstNonZero(cmpString(a.Title, b.Title), cmpString(a.Artist, b.Artist), cmpString(a.Path, b.Path))
	}
}

func missingLocalFile(t playlist.Track) bool {
	if t.Path == "" || t.Stream || playlist.IsURL(t.Path) || strings.HasPrefix(t.Path, "ssh://") {
		return false
	}
	_, err := os.Stat(t.Path)
	return errors.Is(err, os.ErrNotExist)
}

func writeM3U(w io.Writer, tracks []playlist.Track) {
	fmt.Fprintln(w, "#EXTM3U")
	for _, t := range tracks {
		title := t.DisplayName()
		if title == "" {
			title = t.Path
		}
		duration := t.DurationSecs
		if duration <= 0 {
			duration = -1
		}
		fmt.Fprintf(w, "#EXTINF:%d,%s\n", duration, title)
		fmt.Fprintln(w, t.Path)
	}
}

func writePLS(w io.Writer, tracks []playlist.Track) {
	fmt.Fprintln(w, "[playlist]")
	for i, t := range tracks {
		n := i + 1
		fmt.Fprintf(w, "File%d=%s\n", n, t.Path)
		if title := t.DisplayName(); title != "" {
			fmt.Fprintf(w, "Title%d=%s\n", n, title)
		}
		length := t.DurationSecs
		if length <= 0 {
			length = -1
		}
		fmt.Fprintf(w, "Length%d=%d\n", n, length)
	}
	fmt.Fprintf(w, "NumberOfEntries=%d\nVersion=2\n", len(tracks))
}

func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

func sshFindAudio(host string, paths []string) ([]string, error) {
	var nameArgs []string
	first := true
	for ext := range player.SupportedExts {
		if !first {
			nameArgs = append(nameArgs, "-o")
		}
		nameArgs = append(nameArgs, "-name", "'*"+ext+"'")
		first = false
	}

	var allFiles []string
	for _, p := range paths {
		findCmd := fmt.Sprintf("find %s -type f \\( %s \\) | sort",
			shellQuote(p), strings.Join(nameArgs, " "))

		sshArgs := []string{"-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=5", host, findCmd}
		cmd := exec.Command("ssh", sshArgs...)
		out, err := cmd.Output()
		if err != nil {
			return nil, fmt.Errorf("ssh find on %s:%s: %w", host, p, err)
		}

		lines := strings.SplitSeq(strings.TrimSpace(string(out)), "\n")
		for line := range lines {
			line = strings.TrimSpace(line)
			if line != "" {
				allFiles = append(allFiles, line)
			}
		}
	}

	return allFiles, nil
}

func newProvider() (*local.Provider, error) {
	p := local.New()
	if p == nil {
		return nil, fmt.Errorf("failed to initialize local playlist provider")
	}
	return p, nil
}
