// Package embyapi implements the shared Emby/Jellyfin HTTP client. The two
// servers speak nearly the same API; the few differences (auth header scheme,
// ping endpoint, user-id discovery, error prefix, metadata key) are isolated
// in a dialect so emby and jellyfin can be thin wrappers over one client.
package embyapi

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"path"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

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

var defaultHTTPClient = &http.Client{Timeout: 30 * time.Second}

// maxResponseBody limits API responses to 10 MB to prevent unbounded memory growth.
const maxResponseBody = 10 << 20

// Client speaks to an Emby or Jellyfin server over its HTTP API.
type Client struct {
	baseURL    string
	user       string
	password   string
	deviceID   string
	dialect    dialect
	httpClient *http.Client

	// mu guards the lazily-populated fields below, which are read and written
	// from concurrent tea.Cmd goroutines. It is never held across network I/O.
	mu         sync.Mutex
	token      string
	userID     string
	albumCache []Album // cached after first Albums() call
}

// NewEmbyClient returns a Client configured for an Emby server.
func NewEmbyClient(baseURL, token, userID, user, password string) *Client {
	return newClient(baseURL, token, userID, user, password, embyDialect{})
}

// NewJellyfinClient returns a Client configured for a Jellyfin server.
func NewJellyfinClient(baseURL, token, userID, user, password string) *Client {
	return newClient(baseURL, token, userID, user, password, jellyfinDialect{})
}

func newClient(baseURL, token, userID, user, password string, d dialect) *Client {
	return &Client{
		baseURL:    strings.TrimRight(baseURL, "/"),
		token:      token,
		userID:     userID,
		user:       user,
		password:   password,
		deviceID:   "cliamp",
		dialect:    d,
		httpClient: defaultHTTPClient,
	}
}

// SetHTTPClient overrides the HTTP client used for requests. Mainly for tests
// that inject a custom transport.
func (c *Client) SetHTTPClient(hc *http.Client) { c.httpClient = hc }

// authToken returns the current bearer token under the mutex.
func (c *Client) authToken() string {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.token
}

func (c *Client) setUserID(id string) {
	c.mu.Lock()
	c.userID = id
	c.mu.Unlock()
}

// ClearCache discards the cached album list so the next Albums call re-fetches.
func (c *Client) ClearCache() {
	c.mu.Lock()
	c.albumCache = nil
	c.mu.Unlock()
}

// MetaKey returns the playlist.Track ProviderMeta key for this server's item IDs.
func (c *Client) MetaKey() string { return c.dialect.metaKey() }

// Library represents a music library view.
type Library struct {
	ID   string
	Name string
}

const (
	SortAlbumsByName   = "name"
	SortAlbumsByArtist = "artist"
	SortAlbumsByYear   = "year"
)

var albumSortTypes = []provider.SortType{
	{ID: SortAlbumsByName, Label: "Alphabetical by Name"},
	{ID: SortAlbumsByArtist, Label: "Alphabetical by Artist"},
	{ID: SortAlbumsByYear, Label: "By Year"},
}

// Album represents an album entry.
type Album struct {
	ID         string
	Name       string
	Artist     string
	ArtistID   string
	Year       int
	TrackCount int
}

// Track represents a track entry.
type Track struct {
	ID           string
	Name         string
	Artist       string
	Album        string
	Year         int
	TrackNumber  int
	DurationSecs int
}

type userDTO struct {
	ID   string `json:"Id"`
	Name string `json:"Name"`
}

type itemsResponseDTO struct {
	Items            []itemDTO `json:"Items"`
	TotalRecordCount int       `json:"TotalRecordCount"`
}

type itemDTO struct {
	ID             string      `json:"Id"`
	Name           string      `json:"Name"`
	Type           string      `json:"Type"`
	CollectionType string      `json:"CollectionType,omitempty"`
	Album          string      `json:"Album,omitempty"`
	AlbumArtist    string      `json:"AlbumArtist,omitempty"`
	AlbumArtists   []nameIDDTO `json:"AlbumArtists,omitempty"`
	Artists        []string    `json:"Artists,omitempty"`
	ArtistItems    []nameIDDTO `json:"ArtistItems,omitempty"`
	ProductionYear int         `json:"ProductionYear,omitempty"`
	ChildCount     int         `json:"ChildCount,omitempty"`
	IndexNumber    int         `json:"IndexNumber,omitempty"`
	RunTimeTicks   int64       `json:"RunTimeTicks,omitempty"`
}

type nameIDDTO struct {
	ID   string `json:"Id"`
	Name string `json:"Name"`
}

type authResponseDTO struct {
	User struct {
		ID string `json:"Id"`
	} `json:"User"`
	AccessToken string `json:"AccessToken"`
}

type playbackInfo struct {
	CanSeek       bool   `json:"CanSeek"`
	ItemID        string `json:"ItemId"`
	IsPaused      bool   `json:"IsPaused"`
	IsMuted       bool   `json:"IsMuted"`
	PositionTicks int64  `json:"PositionTicks,omitempty"`
	PlayMethod    string `json:"PlayMethod,omitempty"`
}

type playbackStopInfo struct {
	ItemID        string `json:"ItemId"`
	PositionTicks int64  `json:"PositionTicks,omitempty"`
	Failed        bool   `json:"Failed"`
}

// Ping checks that the server is reachable and the token is accepted.
func (c *Client) Ping() error {
	var raw json.RawMessage
	return c.get(c.dialect.pingPath(), nil, &raw)
}

// UserID returns the active user id, discovering it lazily when needed.
func (c *Client) UserID() (string, error) {
	c.mu.Lock()
	id := c.userID
	c.mu.Unlock()
	if id != "" {
		return id, nil
	}
	if err := c.ensureAuth(); err != nil {
		return "", err
	}
	c.mu.Lock()
	id = c.userID
	c.mu.Unlock()
	if id != "" {
		return id, nil
	}
	return c.dialect.discoverUserID(c)
}

// MusicLibraries returns all user views whose collection type is music.
func (c *Client) MusicLibraries() ([]Library, error) {
	userID, err := c.UserID()
	if err != nil {
		return nil, err
	}

	var resp itemsResponseDTO
	if err := c.get("/Users/"+url.PathEscape(userID)+"/Views", nil, &resp); err != nil {
		return nil, err
	}

	var libs []Library
	for _, it := range resp.Items {
		if strings.EqualFold(it.CollectionType, "music") {
			libs = append(libs, Library{ID: it.ID, Name: it.Name})
		}
	}
	return libs, nil
}

// Albums returns all albums across every music library.
// Results are cached after the first successful call.
func (c *Client) Albums() ([]Album, error) {
	c.mu.Lock()
	cached := c.albumCache
	c.mu.Unlock()
	if cached != nil {
		return cached, nil
	}

	libs, err := c.MusicLibraries()
	if err != nil {
		return nil, err
	}

	var out []Album
	for _, lib := range libs {
		albums, err := c.AlbumsByLibrary(lib.ID)
		if err != nil {
			return nil, err
		}
		out = append(out, albums...)
	}
	c.mu.Lock()
	c.albumCache = out
	c.mu.Unlock()
	return out, nil
}

// Artists returns a derived artist list built from the server's album catalog.
func (c *Client) Artists() ([]provider.ArtistInfo, error) {
	albums, err := c.Albums()
	if err != nil {
		return nil, err
	}

	type artistKey struct {
		id   string
		name string
	}
	seen := make(map[artistKey]*provider.ArtistInfo)
	for _, album := range albums {
		key := artistKey{id: canonicalArtistID(album.ArtistID, album.Artist), name: album.Artist}
		if key.id == "" && key.name == "" {
			continue
		}
		info, ok := seen[key]
		if !ok {
			info = &provider.ArtistInfo{
				ID:   key.id,
				Name: key.name,
			}
			seen[key] = info
		}
		info.AlbumCount++
	}

	artists := make([]provider.ArtistInfo, 0, len(seen))
	for _, artist := range seen {
		artists = append(artists, *artist)
	}
	sort.Slice(artists, func(i, j int) bool {
		return strings.ToLower(artists[i].Name) < strings.ToLower(artists[j].Name)
	})
	return artists, nil
}

// ArtistAlbums returns all albums for one artist, derived from the full album list.
func (c *Client) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
	albums, err := c.Albums()
	if err != nil {
		return nil, err
	}

	var out []provider.AlbumInfo
	for _, album := range albums {
		if artistID != "" && album.ArtistID != artistID {
			if canonicalArtistID(album.ArtistID, album.Artist) != artistID {
				continue
			}
		}
		out = append(out, provider.AlbumInfo{
			ID:         album.ID,
			Name:       album.Name,
			Artist:     album.Artist,
			ArtistID:   canonicalArtistID(album.ArtistID, album.Artist),
			Year:       album.Year,
			TrackCount: album.TrackCount,
		})
	}
	sortAlbums(out, SortAlbumsByName)
	return out, nil
}

// AlbumList returns one page from the full album catalog, sorted client-side.
func (c *Client) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
	albums, err := c.Albums()
	if err != nil {
		return nil, err
	}

	out := make([]provider.AlbumInfo, 0, len(albums))
	for _, album := range albums {
		out = append(out, provider.AlbumInfo{
			ID:         album.ID,
			Name:       album.Name,
			Artist:     album.Artist,
			ArtistID:   canonicalArtistID(album.ArtistID, album.Artist),
			Year:       album.Year,
			TrackCount: album.TrackCount,
		})
	}

	sortAlbums(out, sortType)
	if offset < 0 {
		offset = 0
	}
	if offset >= len(out) {
		return nil, nil
	}
	end := len(out)
	if size > 0 && offset+size < end {
		end = offset + size
	}
	return out[offset:end], nil
}

func (c *Client) AlbumSortTypes() []provider.SortType {
	return albumSortTypes
}

func (c *Client) DefaultAlbumSort() string {
	return SortAlbumsByName
}

// AlbumsByLibrary returns all albums under one music library view.
func (c *Client) AlbumsByLibrary(libraryID string) ([]Album, error) {
	userID, err := c.UserID()
	if err != nil {
		return nil, err
	}

	params := url.Values{
		"userId":                 {userID},
		"parentId":               {libraryID},
		"recursive":              {"true"},
		"includeItemTypes":       {"MusicAlbum"},
		"sortBy":                 {"SortName"},
		"sortOrder":              {"Ascending"},
		"enableTotalRecordCount": {"false"},
	}

	var resp itemsResponseDTO
	if err := c.get("/Items", params, &resp); err != nil {
		return nil, err
	}

	out := make([]Album, 0, len(resp.Items))
	for _, it := range resp.Items {
		out = append(out, albumFromItem(it))
	}
	return out, nil
}

// Tracks returns all audio tracks contained by an album item.
func (c *Client) Tracks(albumID string) ([]Track, error) {
	userID, err := c.UserID()
	if err != nil {
		return nil, err
	}

	params := url.Values{
		"userId":                 {userID},
		"parentId":               {albumID},
		"includeItemTypes":       {"Audio"},
		"sortBy":                 {"ParentIndexNumber,IndexNumber,SortName"},
		"sortOrder":              {"Ascending"},
		"fields":                 {"RunTimeTicks"},
		"enableTotalRecordCount": {"false"},
	}

	var resp itemsResponseDTO
	if err := c.get("/Items", params, &resp); err != nil {
		return nil, err
	}

	out := make([]Track, 0, len(resp.Items))
	for _, it := range resp.Items {
		out = append(out, trackFromItem(it))
	}
	return out, nil
}

// Search searches the user's audio library for tracks matching query and
// returns up to limit results.
func (c *Client) Search(query string, limit int) ([]Track, error) {
	userID, err := c.UserID()
	if err != nil {
		return nil, err
	}
	if limit <= 0 {
		limit = 50
	}

	params := url.Values{
		"userId":                 {userID},
		"searchTerm":             {query},
		"includeItemTypes":       {"Audio"},
		"recursive":              {"true"},
		"limit":                  {strconv.Itoa(limit)},
		"fields":                 {"RunTimeTicks"},
		"enableTotalRecordCount": {"false"},
	}

	var resp itemsResponseDTO
	if err := c.get("/Items", params, &resp); err != nil {
		return nil, err
	}

	out := make([]Track, 0, len(resp.Items))
	for _, it := range resp.Items {
		out = append(out, trackFromItem(it))
	}
	return out, nil
}

// IsStreamURL reports whether the given URL looks like an item download
// endpoint. Used by the player to route these URLs through the buffered ffmpeg
// pipeline instead of native HTTP streaming.
func IsStreamURL(path string) bool {
	u, err := url.Parse(path)
	if err != nil {
		return false
	}
	p := strings.ToLower(u.Path)
	return strings.Contains(p, "/items/") && strings.HasSuffix(p, "/download")
}

// StreamURL returns an authenticated audio URL for a track item.
func (c *Client) StreamURL(itemID string) string {
	_ = c.ensureAuth()
	v := url.Values{
		"api_key": {c.authToken()},
	}

	// Use the direct item download route rather than the Audio controller.
	// On the live servers used for validation, the Audio endpoints returned
	// 200 with an empty body, while Download returned the original FLAC/MP3
	// bytes with byte-range support.
	u := c.baseURL + path.Join("/", "Items", itemID, "Download")
	if enc := v.Encode(); enc != "" {
		u += "?" + enc
	}
	return u
}

func (c *Client) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) error {
	return c.postJSON("/Sessions/Playing", playbackInfo{
		CanSeek:       canSeek,
		ItemID:        track.Meta(c.dialect.metaKey()),
		IsPaused:      false,
		IsMuted:       false,
		PositionTicks: toTicks(position),
		PlayMethod:    "DirectPlay",
	})
}

func (c *Client) ReportScrobble(track playlist.Track, elapsed time.Duration, canSeek bool) error {
	progress := playbackInfo{
		CanSeek:       canSeek,
		ItemID:        track.Meta(c.dialect.metaKey()),
		IsPaused:      false,
		IsMuted:       false,
		PositionTicks: toTicks(elapsed),
		PlayMethod:    "DirectPlay",
	}
	if err := c.postJSON("/Sessions/Playing/Progress", progress); err != nil {
		return err
	}
	return c.postJSON("/Sessions/Playing/Stopped", playbackStopInfo{
		ItemID:        track.Meta(c.dialect.metaKey()),
		PositionTicks: toTicks(elapsed),
		Failed:        false,
	})
}

func (c *Client) get(p string, params url.Values, out any) error {
	if err := c.ensureAuth(); err != nil {
		return err
	}

	req, err := c.newRequest(http.MethodGet, p, params)
	if err != nil {
		return err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusOK:
	default:
		return fmt.Errorf("%s: %s: http status %s", c.dialect.name(), p, resp.Status)
	}

	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
	if err != nil {
		return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}
	if err := json.Unmarshal(body, out); err != nil {
		return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}
	return nil
}

func (c *Client) postJSON(p string, payload any) error {
	if err := c.ensureAuth(); err != nil {
		return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}

	body, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}

	req, err := c.newRequestWithBody(http.MethodPost, p, nil, bytes.NewReader(body))
	if err != nil {
		return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
		return fmt.Errorf("%s: %s: http status %s", c.dialect.name(), p, resp.Status)
	}
	io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
	return nil
}

func (c *Client) ensureAuth() error {
	c.mu.Lock()
	have := c.token != ""
	c.mu.Unlock()
	if have {
		return nil
	}
	if c.user == "" || c.password == "" {
		return fmt.Errorf("%s: missing token or user/password", c.dialect.name())
	}

	body, err := json.Marshal(map[string]string{
		"Username": c.user,
		"Pw":       c.password,
	})
	if err != nil {
		return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
	}

	req, err := http.NewRequest(http.MethodPost, c.baseURL+"/Users/AuthenticateByName", bytes.NewReader(body))
	if err != nil {
		return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")
	c.dialect.applyAuth(req, "", "", c.deviceID)

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("%s: auth: http status %s", c.dialect.name(), resp.Status)
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
	if err != nil {
		return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
	}

	var out authResponseDTO
	if err := json.Unmarshal(data, &out); err != nil {
		return fmt.Errorf("%s: auth: %w", c.dialect.name(), err)
	}
	if out.AccessToken == "" {
		return fmt.Errorf("%s: auth: missing access token", c.dialect.name())
	}
	c.mu.Lock()
	c.token = out.AccessToken
	if c.userID == "" {
		c.userID = out.User.ID
	}
	c.mu.Unlock()
	return nil
}

func (c *Client) newRequest(method, p string, params url.Values) (*http.Request, error) {
	return c.newRequestWithBody(method, p, params, nil)
}

func (c *Client) newRequestWithBody(method, p string, params url.Values, body io.Reader) (*http.Request, error) {
	u := c.baseURL + p
	if len(params) > 0 {
		u += "?" + params.Encode()
	}

	req, err := http.NewRequest(method, u, body)
	if err != nil {
		return nil, fmt.Errorf("%s: %s: %w", c.dialect.name(), p, err)
	}
	req.Header.Set("Accept", "application/json")
	c.mu.Lock()
	token, userID := c.token, c.userID
	c.mu.Unlock()
	c.dialect.applyAuth(req, token, userID, c.deviceID)
	return req, nil
}

func albumFromItem(it itemDTO) Album {
	a := Album{
		ID:         it.ID,
		Name:       it.Name,
		Artist:     it.AlbumArtist,
		Year:       it.ProductionYear,
		TrackCount: it.ChildCount,
	}
	if len(it.AlbumArtists) > 0 {
		if a.Artist == "" {
			a.Artist = it.AlbumArtists[0].Name
		}
		a.ArtistID = it.AlbumArtists[0].ID
	}
	if a.Artist == "" && len(it.ArtistItems) > 0 {
		a.Artist = it.ArtistItems[0].Name
		a.ArtistID = it.ArtistItems[0].ID
	}
	return a
}

func trackFromItem(it itemDTO) Track {
	t := Track{
		ID:           it.ID,
		Name:         it.Name,
		Album:        it.Album,
		Year:         it.ProductionYear,
		TrackNumber:  it.IndexNumber,
		DurationSecs: int(it.RunTimeTicks / 10_000_000),
	}
	if len(it.Artists) > 0 {
		t.Artist = it.Artists[0]
	} else if len(it.ArtistItems) > 0 {
		t.Artist = it.ArtistItems[0].Name
	}
	return t
}

func sortAlbums(albums []provider.AlbumInfo, sortType string) {
	switch sortType {
	case "", SortAlbumsByName:
		sort.Slice(albums, func(i, j int) bool {
			if strings.EqualFold(albums[i].Name, albums[j].Name) {
				return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
			}
			return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
		})
	case SortAlbumsByArtist:
		sort.Slice(albums, func(i, j int) bool {
			if strings.EqualFold(albums[i].Artist, albums[j].Artist) {
				return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
			}
			return strings.ToLower(albums[i].Artist) < strings.ToLower(albums[j].Artist)
		})
	case SortAlbumsByYear:
		sort.Slice(albums, func(i, j int) bool {
			if albums[i].Year == albums[j].Year {
				return strings.ToLower(albums[i].Name) < strings.ToLower(albums[j].Name)
			}
			return albums[i].Year > albums[j].Year
		})
	default:
		sortAlbums(albums, SortAlbumsByName)
	}
}

func canonicalArtistID(id, name string) string {
	if id != "" {
		return id
	}
	if name == "" {
		return ""
	}
	return "name:" + strings.ToLower(name)
}

func toTicks(d time.Duration) int64 {
	if d <= 0 {
		return 0
	}
	return d.Nanoseconds() / 100
}
