// Package netease implements a playlist.Provider for NetEase Cloud Music.
package netease

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/bjarneo/cliamp/playlist"
	"github.com/bjarneo/cliamp/provider"
	"github.com/bjarneo/cliamp/resolve"
)

var (
	_ playlist.Provider = (*Provider)(nil)
	_ provider.Searcher = (*Provider)(nil)
)

const (
	defaultAPIBase = "https://music.163.com"
	probeURL       = "https://music.163.com/#/playlist?id=3778678"
	apiTimeout     = 15 * time.Second
	// neteaseCodeOK is the application-level success code in NetEase API
	// responses. It happens to share the value of HTTP 200 but is a distinct
	// field, so it gets its own constant rather than comparing to http.StatusOK.
	neteaseCodeOK = 200
)

// ErrNotAuthenticated is returned when browser cookies do not contain a
// signed-in NetEase Cloud Music account.
var ErrNotAuthenticated = errors.New("netease: browser session is not signed in")

// Config holds settings for the NetEase provider.
type Config struct {
	Enabled     bool
	CookiesFrom string
	UserID      string
}

// IsSet reports whether the provider should be exposed.
func (c Config) IsSet() bool { return c.Enabled }

// Account describes the signed-in NetEase account visible through cookies.
type Account struct {
	UserID   string
	Nickname string
	VIPType  int
}

type chartPlaylist struct {
	id   string
	name string
}

var charts = []chartPlaylist{
	{id: "3778678", name: "Hot Songs"},
	{id: "3779629", name: "New Songs"},
	{id: "19723756", name: "Rising Songs"},
	{id: "2884035", name: "Original Songs"},
}

// Provider implements playlist.Provider and provider.Searcher.
type Provider struct {
	apiBase     string
	httpClient  *http.Client
	cookiesFrom string
	userID      string

	mu           sync.Mutex
	cookieHeader string
	playlists    []playlist.PlaylistInfo
	account      *Account
}

// NewFromConfig returns a provider, or nil when NetEase is not enabled.
// Sets resolve's yt-dlp cookies as a side effect when CookiesFrom is non-empty
// so URL resolution uses the same signed-in browser session.
func NewFromConfig(cfg Config) *Provider {
	if !cfg.Enabled {
		return nil
	}
	cfg.CookiesFrom = strings.TrimSpace(cfg.CookiesFrom)
	if cfg.CookiesFrom != "" {
		resolve.SetYTDLCookiesFrom(cfg.CookiesFrom)
	}
	return New(cfg)
}

// New creates a NetEase provider.
func New(cfg Config) *Provider {
	return &Provider{
		apiBase:     defaultAPIBase,
		httpClient:  &http.Client{Timeout: apiTimeout},
		cookiesFrom: strings.TrimSpace(cfg.CookiesFrom),
		userID:      strings.TrimSpace(cfg.UserID),
	}
}

func newWithBase(cfg Config, base string) *Provider {
	p := New(cfg)
	p.apiBase = strings.TrimRight(base, "/")
	return p
}

func (p *Provider) Name() string { return "NetEase Cloud Music" }

// Refresh clears cached account and playlist state.
func (p *Provider) Refresh() {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.cookieHeader = ""
	p.playlists = nil
	p.account = nil
}

// CheckLogin verifies that the given browser has a signed-in NetEase account.
func CheckLogin(ctx context.Context, browser string) (Account, error) {
	p := New(Config{Enabled: true, CookiesFrom: browser})
	return p.Account(ctx)
}

// Account returns the signed-in account from browser cookies.
func (p *Provider) Account(ctx context.Context) (Account, error) {
	p.mu.Lock()
	if p.account != nil {
		acc := *p.account
		p.mu.Unlock()
		return acc, nil
	}
	p.mu.Unlock()

	var resp accountResponse
	if err := p.apiGet(ctx, "/api/nuser/account/get", nil, &resp); err != nil {
		return Account{}, err
	}
	if resp.Code != neteaseCodeOK {
		return Account{}, fmt.Errorf("netease: account request failed with code %d", resp.Code)
	}
	uid := resp.Account.ID
	if uid == 0 {
		uid = resp.Profile.UserID
	}
	if uid == 0 {
		return Account{}, ErrNotAuthenticated
	}
	acc := Account{
		UserID:   strconv.FormatInt(uid, 10),
		Nickname: resp.Profile.Nickname,
		VIPType:  firstNonZero(resp.Profile.VIPType, resp.Account.VIPType),
	}

	p.mu.Lock()
	p.account = &acc
	if p.userID == "" {
		p.userID = acc.UserID
	}
	p.mu.Unlock()
	return acc, nil
}

// Playlists returns account playlists followed by public chart playlists.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
	p.mu.Lock()
	if p.playlists != nil {
		out := append([]playlist.PlaylistInfo(nil), p.playlists...)
		p.mu.Unlock()
		return out, nil
	}
	userID := p.userID
	p.mu.Unlock()

	ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
	defer cancel()

	var infos []playlist.PlaylistInfo
	if userID == "" && p.cookiesFrom != "" {
		acc, err := p.Account(ctx)
		if err != nil {
			return nil, err
		}
		userID = acc.UserID
	}
	if userID != "" {
		userLists, err := p.userPlaylists(ctx, userID)
		if err != nil {
			return nil, err
		}
		infos = append(infos, userLists...)
	}
	infos = append(infos, chartPlaylists()...)

	p.mu.Lock()
	p.playlists = append([]playlist.PlaylistInfo(nil), infos...)
	p.mu.Unlock()
	return infos, nil
}

// Tracks returns tracks for a user playlist or built-in chart playlist.
func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
	id, err := cleanPlaylistID(playlistID)
	if err != nil {
		return nil, err
	}
	ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
	defer cancel()

	params := url.Values{"id": {id}}
	var resp playlistDetailResponse
	if err := p.apiGet(ctx, "/api/playlist/detail", params, &resp); err != nil {
		return nil, err
	}
	if resp.Code != neteaseCodeOK {
		return nil, fmt.Errorf("netease: playlist detail failed with code %d", resp.Code)
	}
	return songsToTracks(resp.Result.Tracks), nil
}

// SearchTracks searches NetEase songs.
func (p *Provider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
	q := strings.TrimSpace(query)
	if q == "" {
		return nil, nil
	}
	if limit <= 0 {
		limit = 20
	}
	params := url.Values{
		"s":      {q},
		"type":   {"1"},
		"offset": {"0"},
		"limit":  {strconv.Itoa(limit)},
	}
	var resp searchResponse
	if err := p.apiGet(ctx, "/api/search/get/web", params, &resp); err != nil {
		return nil, err
	}
	if resp.Code != neteaseCodeOK {
		return nil, fmt.Errorf("netease: search failed with code %d", resp.Code)
	}
	return songsToTracks(resp.Result.Songs), nil
}

func (p *Provider) userPlaylists(ctx context.Context, userID string) ([]playlist.PlaylistInfo, error) {
	uid, err := strconv.ParseInt(strings.TrimSpace(userID), 10, 64)
	if err != nil || uid <= 0 {
		return nil, fmt.Errorf("netease: invalid user_id %q", userID)
	}
	const pageSize = 100
	var out []playlist.PlaylistInfo
	for offset := 0; ; offset += pageSize {
		params := url.Values{
			"uid":    {strconv.FormatInt(uid, 10)},
			"limit":  {strconv.Itoa(pageSize)},
			"offset": {strconv.Itoa(offset)},
		}
		var resp userPlaylistsResponse
		if err := p.apiGet(ctx, "/api/user/playlist", params, &resp); err != nil {
			return nil, err
		}
		if resp.Code != neteaseCodeOK {
			return nil, fmt.Errorf("netease: playlist request failed with code %d", resp.Code)
		}
		for _, item := range resp.Playlist {
			section := "Saved Playlists"
			if item.UserID == uid {
				section = "My Playlists"
			}
			name := strings.TrimSpace(item.Name)
			if item.SpecialType == 5 {
				name = "Liked Songs"
				section = "My Playlists"
			}
			if name == "" {
				name = "Untitled Playlist"
			}
			out = append(out, playlist.PlaylistInfo{
				ID:         "user:" + strconv.FormatInt(item.ID, 10),
				Name:       name,
				TrackCount: item.TrackCount,
				Section:    section,
			})
		}
		if len(resp.Playlist) < pageSize {
			break
		}
	}
	return out, nil
}

func chartPlaylists() []playlist.PlaylistInfo {
	out := make([]playlist.PlaylistInfo, 0, len(charts))
	for _, chart := range charts {
		out = append(out, playlist.PlaylistInfo{
			ID:      "chart:" + chart.id,
			Name:    chart.name,
			Section: "Charts",
		})
	}
	return out
}

func cleanPlaylistID(id string) (string, error) {
	id = strings.TrimSpace(id)
	if id == "" {
		return "", fmt.Errorf("netease: empty playlist id")
	}
	if v, ok := strings.CutPrefix(id, "user:"); ok {
		id = v
	} else if v, ok := strings.CutPrefix(id, "chart:"); ok {
		id = v
	}
	if _, err := strconv.ParseInt(id, 10, 64); err != nil {
		return "", fmt.Errorf("netease: invalid playlist id %q", id)
	}
	return id, nil
}

func songsToTracks(songs []song) []playlist.Track {
	tracks := make([]playlist.Track, 0, len(songs))
	for _, s := range songs {
		if s.ID == 0 {
			continue
		}
		tracks = append(tracks, playlist.Track{
			Path:         songURL(s.ID),
			Title:        s.Name,
			Artist:       joinArtists(s.Artists),
			Album:        s.Album.Name,
			TrackNumber:  s.TrackNumber,
			Stream:       true,
			DurationSecs: millisToSeconds(s.DurationMS),
			ProviderMeta: map[string]string{provider.MetaNetEaseID: strconv.FormatInt(s.ID, 10)},
		})
	}
	return tracks
}

func songURL(id int64) string {
	return "https://music.163.com/#/song?id=" + strconv.FormatInt(id, 10)
}

func joinArtists(artists []artist) string {
	if len(artists) == 0 {
		return ""
	}
	names := make([]string, 0, len(artists))
	for _, a := range artists {
		if name := strings.TrimSpace(a.Name); name != "" {
			names = append(names, name)
		}
	}
	return strings.Join(names, ", ")
}

func millisToSeconds(ms int) int {
	if ms <= 0 {
		return 0
	}
	return (ms + 999) / 1000
}

func firstNonZero(values ...int) int {
	for _, v := range values {
		if v != 0 {
			return v
		}
	}
	return 0
}

func (p *Provider) apiGet(ctx context.Context, path string, params url.Values, out any) error {
	endpoint, err := url.Parse(p.apiBase + path)
	if err != nil {
		return err
	}
	if params != nil {
		endpoint.RawQuery = params.Encode()
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
	if err != nil {
		return err
	}
	req.Header.Set("User-Agent", "Mozilla/5.0")
	req.Header.Set("Referer", p.apiBase+"/")
	if header, err := p.ensureCookieHeader(ctx); err != nil {
		return err
	} else if header != "" {
		req.Header.Set("Cookie", header)
	}

	resp, err := p.httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("netease: http status %s", resp.Status)
	}
	if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
		return fmt.Errorf("netease: decode response: %w", err)
	}
	return nil
}

func (p *Provider) ensureCookieHeader(ctx context.Context) (string, error) {
	if p.cookiesFrom == "" {
		return "", nil
	}
	p.mu.Lock()
	if p.cookieHeader != "" {
		header := p.cookieHeader
		p.mu.Unlock()
		return header, nil
	}
	p.mu.Unlock()

	header, err := extractBrowserCookieHeader(ctx, p.cookiesFrom)
	if err != nil {
		return "", err
	}
	p.mu.Lock()
	p.cookieHeader = header
	p.mu.Unlock()
	return header, nil
}

func extractBrowserCookieHeader(ctx context.Context, browser string) (string, error) {
	if _, err := exec.LookPath("yt-dlp"); err != nil {
		return "", fmt.Errorf("yt-dlp not found. Install with: %s", ytDLPInstallHint())
	}
	tmp, err := os.CreateTemp("", "cliamp-netease-cookies-*.txt")
	if err != nil {
		return "", err
	}
	path := tmp.Name()
	tmp.Close()
	os.Remove(path)
	defer os.Remove(path)

	cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	cmd := exec.CommandContext(cmdCtx, "yt-dlp",
		"--cookies-from-browser", browser,
		"--cookies", path,
		"--flat-playlist",
		"--playlist-end", "1",
		"--socket-timeout", "15",
		"--print", "title",
		probeURL,
	)
	var stderr strings.Builder
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		msg := strings.TrimSpace(stderr.String())
		if msg != "" {
			return "", fmt.Errorf("netease: load browser cookies: %s: %w", msg, err)
		}
		return "", fmt.Errorf("netease: load browser cookies: %w", err)
	}
	header, err := cookieHeaderFromNetscapeFile(path)
	if err != nil {
		return "", err
	}
	if header == "" {
		return "", fmt.Errorf("netease: no NetEase cookies found in browser session")
	}
	return header, nil
}

func ytDLPInstallHint() string {
	switch runtime.GOOS {
	case "darwin":
		return "brew install yt-dlp"
	case "linux":
		if _, err := exec.LookPath("apt-get"); err == nil {
			return "sudo apt install yt-dlp"
		}
		if _, err := exec.LookPath("pacman"); err == nil {
			return "sudo pacman -S yt-dlp"
		}
		return "pip install yt-dlp"
	case "windows":
		return "winget install yt-dlp"
	default:
		return "pip install yt-dlp"
	}
}

func cookieHeaderFromNetscapeFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	seen := map[string]bool{}
	var pairs []string
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" || strings.HasPrefix(line, "# Netscape") || strings.HasPrefix(line, "# This file") {
			continue
		}
		fields := strings.Split(line, "\t")
		if len(fields) < 7 {
			continue
		}
		domain := strings.TrimPrefix(fields[0], "#HttpOnly_")
		if !isNetEaseCookieDomain(domain) {
			continue
		}
		name := fields[5]
		value := fields[6]
		if name == "" || seen[name] {
			continue
		}
		seen[name] = true
		pairs = append(pairs, name+"="+value)
	}
	if err := scanner.Err(); err != nil {
		return "", err
	}
	return strings.Join(pairs, "; "), nil
}

func isNetEaseCookieDomain(domain string) bool {
	domain = strings.TrimPrefix(strings.ToLower(domain), ".")
	return domain == "163.com" || domain == "music.163.com" || strings.HasSuffix(domain, ".music.163.com")
}

type accountResponse struct {
	Code    int `json:"code"`
	Account struct {
		ID      int64 `json:"id"`
		VIPType int   `json:"vipType"`
	} `json:"account"`
	Profile struct {
		UserID   int64  `json:"userId"`
		Nickname string `json:"nickname"`
		VIPType  int    `json:"vipType"`
	} `json:"profile"`
}

type userPlaylistsResponse struct {
	Code     int            `json:"code"`
	Playlist []playlistItem `json:"playlist"`
}

type playlistItem struct {
	ID          int64  `json:"id"`
	Name        string `json:"name"`
	UserID      int64  `json:"userId"`
	TrackCount  int    `json:"trackCount"`
	SpecialType int    `json:"specialType"`
}

type playlistDetailResponse struct {
	Code   int `json:"code"`
	Result struct {
		Tracks []song `json:"tracks"`
	} `json:"result"`
}

type searchResponse struct {
	Code   int `json:"code"`
	Result struct {
		Songs []song `json:"songs"`
	} `json:"result"`
}

type song struct {
	ID          int64    `json:"id"`
	Name        string   `json:"name"`
	DurationMS  int      `json:"duration"`
	TrackNumber int      `json:"no"`
	Artists     []artist `json:"artists"`
	Album       album    `json:"album"`
}

type artist struct {
	Name string `json:"name"`
}

type album struct {
	Name string `json:"name"`
}
