// Package plex implements a playlist.Provider for Plex Media Server.
package plex

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"
)

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

// apiClient is used for all Plex API calls with a finite timeout.
// It is distinct from httpclient.Streaming (which has no timeout) used for audio streams.
var apiClient = &http.Client{Timeout: 30 * time.Second}

// Client speaks to a Plex Media Server over its HTTP API.
type Client struct {
	baseURL   string   // e.g. "http://192.168.1.10:32400"
	token     string   // X-Plex-Token
	libraries []string // if non-empty, MusicSections filters to these titles (case-insensitive)
}

// NewClient returns a Client for the given server URL and authentication token.
// libraries is an optional list of music library names to restrict MusicSections to.
// When not provided, all music libraries will be loaded
func NewClient(baseURL, token string, libraries ...string) *Client {
	return &Client{baseURL: baseURL, token: token, libraries: libraries}
}

// Section represents a Plex library section (e.g. a music library).
type Section struct {
	Key   string // numeric section ID, e.g. "3"
	Title string // display name
	Type  string // "artist" for music libraries
}

// Album represents a Plex album with its artist and track count.
type Album struct {
	RatingKey  string // unique album ID (Plex ratingKey)
	Title      string
	ArtistName string // parentTitle in the Plex API
	Year       int
	TrackCount int // leafCount
}

// Track represents a Plex track with metadata and its first streamable Part.
type Track struct {
	RatingKey   string
	Title       string
	ArtistName  string // grandparentTitle
	AlbumName   string // parentTitle
	Year        int
	TrackNumber int    // index field in Plex API
	Duration    int    // milliseconds
	PartKey     string // relative path, e.g. "/library/parts/67890/1234567890/file.flac"
}

// get issues an authenticated GET request and decodes the JSON response into result.
func (c *Client) get(path string, params url.Values, result any) error {
	if params == nil {
		params = url.Values{}
	}
	params.Set("X-Plex-Token", c.token)

	req, err := http.NewRequest(http.MethodGet, c.baseURL+path+"?"+params.Encode(), nil)
	if err != nil {
		return fmt.Errorf("plex: %s: %w", path, err)
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("X-Plex-Product", "cliamp")
	req.Header.Set("X-Plex-Client-Identifier", "cliamp")

	resp, err := apiClient.Do(req)
	if err != nil {
		return fmt.Errorf("plex: %s: server unreachable: %w", path, err)
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusOK:
		// ok
	case http.StatusUnauthorized:
		return fmt.Errorf("plex: token invalid or expired")
	default:
		return fmt.Errorf("plex: %s: HTTP %s", path, resp.Status)
	}

	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
	if err != nil {
		return fmt.Errorf("plex: %s: %w", path, err)
	}
	return json.Unmarshal(body, result)
}

// Ping checks that the server is reachable and the token is valid.
// Returns a descriptive error on failure.
func (c *Client) Ping() error {
	var result struct {
		MediaContainer struct {
			FriendlyName string `json:"friendlyName"`
		} `json:"MediaContainer"`
	}
	return c.get("/", nil, &result)
}

// MusicSections returns library sections of type "artist" (music libraries).
// When the client was constructed with a library filter, only sections whose
// title matches one of the allowed names (case-insensitive) are returned.
func (c *Client) MusicSections() ([]Section, error) {
	var result struct {
		MediaContainer struct {
			Directory []struct {
				Key   string `json:"key"`
				Type  string `json:"type"`
				Title string `json:"title"`
			} `json:"Directory"`
		} `json:"MediaContainer"`
	}
	if err := c.get("/library/sections", nil, &result); err != nil {
		return nil, err
	}

	var sections []Section
	for _, d := range result.MediaContainer.Directory {
		if d.Type == "artist" && c.includeLibrary(d.Title) {
			sections = append(sections, Section{
				Key:   d.Key,
				Title: d.Title,
				Type:  d.Type,
			})
		}
	}
	return sections, nil
}

// includeLibrary reports whether the library with the given title should be
// included. When no filter is configured (libraries is empty) all libraries pass.
func (c *Client) includeLibrary(title string) bool {
	if len(c.libraries) == 0 {
		return true
	}
	for _, lib := range c.libraries {
		if strings.EqualFold(lib, title) {
			return true
		}
	}
	return false
}

// pageSize is the number of albums requested per API call.
// Plex paginates /library/sections/{id}/all; without an explicit size the
// server may return as few as one item.
const pageSize = 300

// Albums returns all albums in the given music section (identified by its key).
// It requests type=9 (album) directly rather than walking artists, and paginates
// through the full result set using X-Plex-Container-Start / Size.
func (c *Client) Albums(sectionKey string) ([]Album, error) {
	type albumPage struct {
		MediaContainer struct {
			TotalSize int `json:"totalSize"`
			Metadata  []struct {
				RatingKey   string `json:"ratingKey"`
				Title       string `json:"title"`
				ParentTitle string `json:"parentTitle"` // artist name
				Year        int    `json:"year"`
				LeafCount   int    `json:"leafCount"` // track count
			} `json:"Metadata"`
		} `json:"MediaContainer"`
	}

	var albums []Album
	for offset := 0; ; offset += pageSize {
		params := url.Values{
			"type":                   {"9"}, // 9 = album
			"X-Plex-Container-Start": {fmt.Sprintf("%d", offset)},
			"X-Plex-Container-Size":  {fmt.Sprintf("%d", pageSize)},
		}
		var page albumPage
		if err := c.get("/library/sections/"+sectionKey+"/all", params, &page); err != nil {
			return nil, err
		}
		for _, m := range page.MediaContainer.Metadata {
			albums = append(albums, Album{
				RatingKey:  m.RatingKey,
				Title:      m.Title,
				ArtistName: m.ParentTitle,
				Year:       m.Year,
				TrackCount: m.LeafCount,
			})
		}
		// Stop when we've fetched everything.
		if offset+pageSize >= page.MediaContainer.TotalSize {
			break
		}
	}
	return albums, nil
}

// Tracks returns all tracks in the given album (identified by its ratingKey).
func (c *Client) Tracks(albumRatingKey string) ([]Track, error) {
	var result struct {
		MediaContainer struct {
			Metadata []trackJSON `json:"Metadata"`
		} `json:"MediaContainer"`
	}
	if err := c.get("/library/metadata/"+albumRatingKey+"/children", nil, &result); err != nil {
		return nil, err
	}

	tracks := make([]Track, 0, len(result.MediaContainer.Metadata))
	for _, m := range result.MediaContainer.Metadata {
		tracks = append(tracks, trackFromJSON(m))
	}
	return tracks, nil
}

// Search searches the music library for tracks matching query.
// Returns nil without making an HTTP call when query is empty.
func (c *Client) Search(query string) ([]Track, error) {
	if query == "" {
		return nil, nil
	}
	var result struct {
		MediaContainer struct {
			Metadata []trackJSON `json:"Metadata"`
		} `json:"MediaContainer"`
	}
	params := url.Values{
		"query": {query},
		"type":  {"10"}, // 10 = track
	}
	if err := c.get("/library/search", params, &result); err != nil {
		return nil, err
	}

	tracks := make([]Track, 0, len(result.MediaContainer.Metadata))
	for _, m := range result.MediaContainer.Metadata {
		tracks = append(tracks, trackFromJSON(m))
	}
	return tracks, nil
}

// StreamURL returns the authenticated HTTP URL for streaming a track part.
// partKey is the relative path from the Part element, e.g. "/library/parts/…/file.flac".
// The token is appended as a query parameter; Plex accepts it in either header or query form.
func (c *Client) StreamURL(partKey string) string {
	return c.baseURL + partKey + "?X-Plex-Token=" + url.QueryEscape(c.token)
}

// IsStreamURL reports whether the given URL looks like a Plex library part
// endpoint. Used by the player to route these URLs through the buffered
// navBuffer + ffmpeg pipeline instead of native HTTP streaming.
func IsStreamURL(urlStr string) bool {
	u, err := url.Parse(urlStr)
	if err != nil {
		return false
	}
	return strings.Contains(strings.ToLower(u.Path), "/library/parts/")
}

// trackJSON is the shared JSON structure for track responses (children and search).
type trackJSON struct {
	RatingKey        string `json:"ratingKey"`
	Title            string `json:"title"`
	GrandparentTitle string `json:"grandparentTitle"` // artist
	ParentTitle      string `json:"parentTitle"`      // album
	Year             int    `json:"year"`
	Index            int    `json:"index"`    // track number within album
	Duration         int    `json:"duration"` // milliseconds
	Media            []struct {
		Part []struct {
			Key string `json:"key"`
		} `json:"Part"`
	} `json:"Media"`
}

func trackFromJSON(m trackJSON) Track {
	var partKey string
	if len(m.Media) > 0 && len(m.Media[0].Part) > 0 {
		partKey = m.Media[0].Part[0].Key
	}
	return Track{
		RatingKey:   m.RatingKey,
		Title:       m.Title,
		ArtistName:  m.GrandparentTitle,
		AlbumName:   m.ParentTitle,
		Year:        m.Year,
		TrackNumber: m.Index,
		Duration:    m.Duration,
		PartKey:     partKey,
	}
}
