package ytmusic

import (
	"context"
	"fmt"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/bjarneo/cliamp/playlist"

	"google.golang.org/api/youtube/v3"
)

// itemInfo holds metadata for a single video in a playlist.
type itemInfo struct {
	videoID string
	title   string
	channel string
}

// youtubeAPIBatchSize is the maximum number of items per YouTube Data API request.
const youtubeAPIBatchSize = 50

// baseProvider holds shared state for YouTube and YouTube Music providers.
// Both providers share the same OAuth session and track cache.
type baseProvider struct {
	session      *Session
	clientID     string
	clientSecret string
	hasCookies   bool // true when cookies_from is configured
	mu           sync.Mutex
	trackCache   map[string][]playlist.Track // playlist ID -> cached tracks
	allPlaylists []playlistEntry             // cached raw playlist list
	classified   map[string]bool             // playlist ID -> is music (from classify.go)
	disk         *ytCache                    // lazy-loaded disk cache
	authCancel   context.CancelFunc          // cancels any in-progress OAuth flow
}

func newBase(session *Session, clientID, clientSecret string, hasCookies bool) *baseProvider {
	return &baseProvider{
		session:      session,
		clientID:     clientID,
		clientSecret: clientSecret,
		hasCookies:   hasCookies,
		trackCache:   make(map[string][]playlist.Track),
	}
}

// ensureDiskCache lazily loads the disk cache. Must be called under mu.
func (b *baseProvider) ensureDiskCache() *ytCache {
	if b.disk == nil {
		b.disk = loadYTCache()
	}
	return b.disk
}

// initSession creates a session if one doesn't exist yet. If interactive is
// false, only stored credentials are tried (returning ErrNeedsAuth on failure).
// If interactive is true, a browser-based OAuth flow is started. Any previous
// in-progress OAuth flow is cancelled first to free the callback port.
func (b *baseProvider) initSession(interactive bool) error {
	b.mu.Lock()
	if b.session != nil {
		b.mu.Unlock()
		return nil
	}
	// Cancel any previous in-progress auth attempt so the old listener
	// on CallbackPort is released before we try to bind again.
	if b.authCancel != nil {
		b.authCancel()
		b.authCancel = nil
	}
	clientID := b.clientID
	clientSecret := b.clientSecret
	b.mu.Unlock()

	if clientID == "" {
		return fmt.Errorf("ytmusic: no client ID available")
	}

	var sess *Session
	var err error
	if interactive {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
		b.mu.Lock()
		b.authCancel = cancel
		b.mu.Unlock()

		sess, err = NewSession(ctx, clientID, clientSecret)

		b.mu.Lock()
		b.authCancel = nil
		b.mu.Unlock()
		cancel()
	} else {
		sess, err = NewSessionSilent(context.Background(), clientID, clientSecret)
	}
	if err != nil {
		if !interactive {
			return playlist.ErrNeedsAuth
		}
		return err
	}

	b.mu.Lock()
	if b.session == nil {
		b.session = sess
	}
	b.mu.Unlock()
	return nil
}

func (b *baseProvider) ensureSession() error { return b.initSession(false) }
func (b *baseProvider) authenticate() error  { return b.initSession(true) }

// refresh clears playlist/track caches so the next call re-fetches from the
// API. Classification is preserved — it's on disk in a separate file, rarely
// changes, and re-classifying costs API quota.
func (b *baseProvider) refresh() {
	b.mu.Lock()
	b.allPlaylists = nil
	b.classified = nil
	clear(b.trackCache)
	dc := b.ensureDiskCache()
	dc.clear()
	snap := dc.snapshot()
	b.mu.Unlock()

	saveSnapshot(snap)
}

func (b *baseProvider) close() {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.authCancel != nil {
		b.authCancel()
		b.authCancel = nil
	}
	if b.session != nil {
		b.session.Close()
		b.session = nil
	}
}

// fetchAndClassify loads all playlists and classifies them as music vs non-music.
// Results are cached in-memory for the session and on disk for fast startup.
// If fresh disk cache exists with full classification, no API calls or auth are needed.
func (b *baseProvider) fetchAndClassify() error {
	b.mu.Lock()
	if b.allPlaylists != nil {
		b.mu.Unlock()
		return nil
	}

	// Try disk cache first — no session/auth needed if data is fresh.
	dc := b.ensureDiskCache()
	if dc.playlistsFresh() {
		classified := loadClassification()
		if classified != nil {
			allClassified := true
			for _, pl := range dc.Playlists {
				if _, ok := classified[pl.ID]; !ok {
					allClassified = false
					break
				}
			}
			if allClassified {
				b.allPlaylists = dc.Playlists
				b.classified = classified
				b.mu.Unlock()
				return nil
			}
		}
	}
	b.mu.Unlock()

	// Disk cache stale or incomplete — fetch from API.
	if err := b.ensureSession(); err != nil {
		return err
	}

	b.mu.Lock()
	sess := b.session
	b.mu.Unlock()
	if sess == nil {
		return fmt.Errorf("ytmusic: session unavailable")
	}
	svc := sess.Service()
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	var all []playlistEntry
	seen := make(map[string]bool)

	pageToken := ""
	for {
		call := svc.Playlists.List([]string{"snippet", "contentDetails"}).
			Mine(true).
			MaxResults(50).
			Context(ctx)
		if pageToken != "" {
			call = call.PageToken(pageToken)
		}

		resp, err := call.Do()
		if err != nil {
			return fmt.Errorf("ytmusic: list playlists: %w", err)
		}

		for _, item := range resp.Items {
			count := int(item.ContentDetails.ItemCount)
			if count <= 0 || seen[item.Id] {
				continue
			}
			seen[item.Id] = true
			all = append(all, playlistEntry{
				ID:         item.Id,
				Name:       item.Snippet.Title,
				TrackCount: count,
			})
		}

		if resp.NextPageToken == "" {
			break
		}
		pageToken = resp.NextPageToken
	}

	// Classify playlists (parallel, with disk cache).
	// Pass nil to let classifyPlaylists load from disk itself —
	// the earlier loadClassification() was skipped because cache was stale.
	classified := classifyWithTimeout(svc, all, 60*time.Second, nil)

	b.mu.Lock()
	if b.allPlaylists != nil {
		// Another goroutine completed fetchAndClassify while we were fetching.
		b.mu.Unlock()
		return nil
	}
	b.allPlaylists = all
	b.classified = classified
	// Persist playlists to disk cache.
	dc = b.ensureDiskCache()
	dc.setPlaylists(all)
	snap := dc.snapshot()
	b.mu.Unlock()

	// Save outside the lock — disk I/O shouldn't block other goroutines.
	saveSnapshot(snap)

	return nil
}

// filteredPlaylists returns playlists filtered by music classification.
func (b *baseProvider) filteredPlaylists(wantMusic bool) []playlist.PlaylistInfo {
	b.mu.Lock()
	defer b.mu.Unlock()

	var result []playlist.PlaylistInfo
	for _, pl := range b.allPlaylists {
		isMusic, ok := b.classified[pl.ID]
		if !ok {
			isMusic = false
		}
		if isMusic == wantMusic {
			result = append(result, playlist.PlaylistInfo{
				ID:         pl.ID,
				Name:       pl.Name,
				TrackCount: pl.TrackCount,
			})
		}
	}
	return result
}

// tracks fetches tracks for a playlist (shared between both providers).
// Checks in-memory cache, then disk cache, then fetches from API.
func (b *baseProvider) tracks(playlistID string) ([]playlist.Track, error) {
	b.mu.Lock()
	if cached, ok := b.trackCache[playlistID]; ok {
		b.mu.Unlock()
		return cached, nil
	}

	// Check disk cache — avoids API call and auth if fresh.
	dc := b.ensureDiskCache()
	if tracks, ok := dc.tracksFresh(playlistID); ok {
		b.trackCache[playlistID] = tracks
		b.mu.Unlock()
		return tracks, nil
	}
	b.mu.Unlock()

	// Disk cache miss — fetch from API.
	if err := b.ensureSession(); err != nil {
		return nil, err
	}

	b.mu.Lock()
	sess := b.session
	b.mu.Unlock()
	if sess == nil {
		return nil, fmt.Errorf("ytmusic: session unavailable")
	}
	svc := sess.Service()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	var items []itemInfo

	pageToken := ""
	for {
		call := svc.PlaylistItems.List([]string{"snippet", "contentDetails"}).
			PlaylistId(playlistID).
			MaxResults(50).
			Context(ctx)
		if pageToken != "" {
			call = call.PageToken(pageToken)
		}

		resp, err := call.Do()
		if err != nil {
			return nil, fmt.Errorf("ytmusic: list playlist items: %w", err)
		}

		for _, item := range resp.Items {
			vid := item.ContentDetails.VideoId
			if vid == "" {
				continue
			}
			title := item.Snippet.Title
			if title == "Private video" || title == "Deleted video" {
				continue
			}
			channel := item.Snippet.VideoOwnerChannelTitle
			if !b.hasCookies && (channel == "Music Library Uploads" || channel == "") {
				continue
			}
			items = append(items, itemInfo{
				videoID: vid,
				title:   title,
				channel: channel,
			})
		}

		if resp.NextPageToken == "" {
			break
		}
		pageToken = resp.NextPageToken
	}

	durations := b.fetchDurations(ctx, svc, items)

	var tracks []playlist.Track
	for _, it := range items {
		tracks = append(tracks, playlist.Track{
			Path:         "https://music.youtube.com/watch?v=" + it.videoID,
			Title:        it.title,
			Artist:       cleanChannelName(it.channel),
			Stream:       false,
			DurationSecs: durations[it.videoID],
		})
	}

	// Persist to in-memory and disk cache.
	b.mu.Lock()
	b.trackCache[playlistID] = tracks
	dc = b.ensureDiskCache()
	dc.setTracks(playlistID, tracks)
	snap := dc.snapshot()
	b.mu.Unlock()

	// Save outside the lock — disk I/O shouldn't block other goroutines.
	saveSnapshot(snap)

	return tracks, nil
}

func (b *baseProvider) fetchDurations(ctx context.Context, svc *youtube.Service, items []itemInfo) map[string]int {
	var mu sync.Mutex
	durations := make(map[string]int)
	var wg sync.WaitGroup
	sem := make(chan struct{}, 5) // limit concurrent API calls

	for i := 0; i < len(items); i += youtubeAPIBatchSize {
		end := min(i+youtubeAPIBatchSize, len(items))
		batch := items[i:end]

		wg.Go(func() {
			sem <- struct{}{}
			defer func() { <-sem }()

			var ids []string
			for _, it := range batch {
				ids = append(ids, it.videoID)
			}
			vResp, err := svc.Videos.List([]string{"contentDetails"}).
				Id(ids...).
				Context(ctx).
				Do()
			if err != nil {
				return
			}
			mu.Lock()
			for _, v := range vResp.Items {
				durations[v.Id] = parseISO8601Duration(v.ContentDetails.Duration)
			}
			mu.Unlock()
		})
	}

	wg.Wait()
	return durations
}

// Special auto-generated playlist IDs exposed by the YouTube Data API.
const (
	playlistIDLikedMusic  = "LM" // YouTube Music liked songs
	playlistIDLikedVideos = "LL" // YouTube liked videos
)

// playlistCounts fetches item counts for the given playlist IDs in a single
// API call. IDs not exposed by the API are omitted from the result.
// Returns an empty map when no session is available (e.g. disk-cache-only boot).
func (b *baseProvider) playlistCounts(ids ...string) map[string]int {
	out := make(map[string]int, len(ids))
	if len(ids) == 0 {
		return out
	}
	b.mu.Lock()
	sess := b.session
	b.mu.Unlock()
	if sess == nil {
		return out
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	resp, err := sess.Service().Playlists.List([]string{"contentDetails"}).
		Id(ids...).
		Context(ctx).
		Do()
	if err != nil {
		return out
	}
	for _, item := range resp.Items {
		out[item.Id] = int(item.ContentDetails.ItemCount)
	}
	return out
}

// ─── YouTube Music Provider ────────────────────────────────────────────────

// YouTubeMusicProvider shows playlists classified as music content.
type YouTubeMusicProvider struct {
	base *baseProvider
}

func (p *YouTubeMusicProvider) Name() string        { return "YouTube Music" }
func (p *YouTubeMusicProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeMusicProvider) Close()              { p.base.close() }
func (p *YouTubeMusicProvider) Refresh()            { p.base.refresh() }
func (p *YouTubeMusicProvider) Tracks(id string) ([]playlist.Track, error) {
	return p.base.tracks(id)
}

func (p *YouTubeMusicProvider) Playlists() ([]playlist.PlaylistInfo, error) {
	if err := p.base.fetchAndClassify(); err != nil {
		return nil, err
	}

	counts := p.base.playlistCounts(playlistIDLikedMusic)
	all := []playlist.PlaylistInfo{{
		ID:         playlistIDLikedMusic,
		Name:       "Liked Music",
		TrackCount: counts[playlistIDLikedMusic],
	}}
	all = append(all, p.base.filteredPlaylists(true)...)
	return all, nil
}

// ─── YouTube Provider ──────────────────────────────────────────────────────

// YouTubeProvider shows playlists classified as non-music (video) content.
type YouTubeProvider struct {
	base *baseProvider
}

func (p *YouTubeProvider) Name() string        { return "YouTube" }
func (p *YouTubeProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeProvider) Close()              { /* shared base; closed via music provider */ }
func (p *YouTubeProvider) Refresh()            { p.base.refresh() }
func (p *YouTubeProvider) Tracks(id string) ([]playlist.Track, error) {
	return p.base.tracks(id)
}

func (p *YouTubeProvider) Playlists() ([]playlist.PlaylistInfo, error) {
	if err := p.base.fetchAndClassify(); err != nil {
		return nil, err
	}

	counts := p.base.playlistCounts(playlistIDLikedVideos)
	all := []playlist.PlaylistInfo{{
		ID:         playlistIDLikedVideos,
		Name:       "Liked Videos",
		TrackCount: counts[playlistIDLikedVideos],
	}}
	all = append(all, p.base.filteredPlaylists(false)...)
	return all, nil
}

// ─── YouTube All Provider ──────────────────────────────────────────────────

// YouTubeAllProvider shows all playlists regardless of classification.
type YouTubeAllProvider struct {
	base *baseProvider
}

func (p *YouTubeAllProvider) Name() string        { return "YouTube (All)" }
func (p *YouTubeAllProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeAllProvider) Close()              { /* shared base; closed via music provider */ }
func (p *YouTubeAllProvider) Refresh()            { p.base.refresh() }
func (p *YouTubeAllProvider) Tracks(id string) ([]playlist.Track, error) {
	return p.base.tracks(id)
}

func (p *YouTubeAllProvider) Playlists() ([]playlist.PlaylistInfo, error) {
	if err := p.base.fetchAndClassify(); err != nil {
		return nil, err
	}

	counts := p.base.playlistCounts(playlistIDLikedMusic, playlistIDLikedVideos)
	all := []playlist.PlaylistInfo{
		{ID: playlistIDLikedMusic, Name: "Liked Music", TrackCount: counts[playlistIDLikedMusic]},
		{ID: playlistIDLikedVideos, Name: "Liked Videos", TrackCount: counts[playlistIDLikedVideos]},
	}

	b := p.base
	b.mu.Lock()
	for _, pl := range b.allPlaylists {
		all = append(all, playlist.PlaylistInfo{
			ID:         pl.ID,
			Name:       pl.Name,
			TrackCount: pl.TrackCount,
		})
	}
	b.mu.Unlock()

	return all, nil
}

// ─── Constructor ───────────────────────────────────────────────────────────

// Providers holds the YouTube Music, YouTube, and YouTube All providers,
// sharing a single OAuth session.
type Providers struct {
	Music *YouTubeMusicProvider
	Video *YouTubeProvider
	All   *YouTubeAllProvider
}

// New creates all three YouTube providers with a shared session.
func New(session *Session, clientID, clientSecret string, hasCookies bool) Providers {
	base := newBase(session, clientID, clientSecret, hasCookies)
	return Providers{
		Music: &YouTubeMusicProvider{base: base},
		Video: &YouTubeProvider{base: base},
		All:   &YouTubeAllProvider{base: base},
	}
}

// ─── Helpers ───────────────────────────────────────────────────────────────

var iso8601Re = regexp.MustCompile(`P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?`)

func parseISO8601Duration(d string) int {
	m := iso8601Re.FindStringSubmatch(d)
	if m == nil {
		return 0
	}
	var total int
	if m[1] != "" {
		v, _ := strconv.Atoi(m[1])
		total += v * 86400
	}
	if m[2] != "" {
		v, _ := strconv.Atoi(m[2])
		total += v * 3600
	}
	if m[3] != "" {
		v, _ := strconv.Atoi(m[3])
		total += v * 60
	}
	if m[4] != "" {
		v, _ := strconv.Atoi(m[4])
		total += v
	}
	return total
}

func cleanChannelName(name string) string {
	name = strings.TrimSuffix(name, " - Topic")
	return name
}
