// Package tracearr reads per-user playback history from Tracearr's public API.
//
// Tracearr is deliberately kept behind the Memby gateway: its operator API key never
// reaches a television, and a Tracearr outage can only reduce recommendation quality.
package tracearr

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
	"unicode"
)

type Client struct {
	baseURL  string
	apiKey   string
	serverID string
	http     *http.Client
}

type Session struct {
	ID               string        `json:"id"`
	ServerID         string        `json:"serverId"`
	State            string        `json:"state"`
	MediaType        string        `json:"mediaType"`
	MediaTitle       string        `json:"mediaTitle"`
	ShowTitle        string        `json:"showTitle"`
	SeasonNumber     *int          `json:"seasonNumber"`
	EpisodeNumber    *int          `json:"episodeNumber"`
	Year             *int          `json:"year"`
	DurationMs       FlexibleInt64 `json:"durationMs"`
	ProgressMs       FlexibleInt64 `json:"progressMs"`
	TotalDurationMs  FlexibleInt64 `json:"totalDurationMs"`
	StartedAt        string        `json:"startedAt"`
	StoppedAt        string        `json:"stoppedAt"`
	Watched          bool          `json:"watched"`
	Device           string        `json:"device"`
	Player           string        `json:"player"`
	Product          string        `json:"product"`
	Platform         string        `json:"platform"`
	IsTranscode      bool          `json:"isTranscode"`
	VideoDecision    string        `json:"videoDecision"`
	AudioDecision    string        `json:"audioDecision"`
	SourceVideoCodec string        `json:"sourceVideoCodec"`
	SourceAudioCodec string        `json:"sourceAudioCodec"`
	User             struct {
		ID       string `json:"id"`
		Username string `json:"username"`
	} `json:"user"`
}

// FlexibleInt64 accepts both JSON numbers and quoted integers. Tracearr currently
// serialises progress fields as strings while duration is numeric.
type FlexibleInt64 int64

func (v *FlexibleInt64) UnmarshalJSON(raw []byte) error {
	raw = bytes.TrimSpace(raw)
	if bytes.Equal(raw, []byte("null")) || len(raw) == 0 {
		*v = 0
		return nil
	}
	if raw[0] == '"' {
		var value string
		if err := json.Unmarshal(raw, &value); err != nil {
			return err
		}
		parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
		if err != nil {
			return fmt.Errorf("tracearr integer %q: %w", value, err)
		}
		*v = FlexibleInt64(parsed)
		return nil
	}
	parsed, err := strconv.ParseInt(string(raw), 10, 64)
	if err != nil {
		return fmt.Errorf("tracearr integer %q: %w", string(raw), err)
	}
	*v = FlexibleInt64(parsed)
	return nil
}

type Page struct {
	Data []Session `json:"data"`
	Meta struct {
		Total    int `json:"total"`
		Page     int `json:"page"`
		PageSize int `json:"pageSize"`
	} `json:"meta"`
}

type User struct {
	ID           string `json:"id"`
	Username     string `json:"username"`
	SessionCount int    `json:"sessionCount"`
}

type UserPage struct {
	Data []User `json:"data"`
	Meta struct {
		Total    int `json:"total"`
		Page     int `json:"page"`
		PageSize int `json:"pageSize"`
	} `json:"meta"`
}

func New(baseURL, apiKey, serverID string, timeout time.Duration) *Client {
	return &Client{
		baseURL:  strings.TrimRight(strings.TrimSpace(baseURL), "/"),
		apiKey:   strings.TrimSpace(apiKey),
		serverID: strings.TrimSpace(serverID),
		http:     &http.Client{Timeout: timeout},
	}
}

// History returns the newest sessions belonging to username. Tracearr's public history
// endpoint currently has no user filter, so Memby pages through the bounded recent
// window and filters locally. Usernames are the stable identity shared by Emby and
// Tracearr; no fuzzy matching is used.
func (c *Client) History(ctx context.Context, username string, limit int) ([]Session, error) {
	if c == nil || c.baseURL == "" || c.apiKey == "" || strings.TrimSpace(username) == "" {
		return nil, nil
	}
	if limit <= 0 {
		limit = 100
	}
	const pageSize = 100
	const maxPages = 10

	wanted := strings.TrimSpace(username)
	out := make([]Session, 0, min(limit, pageSize))
	for pageNumber := 1; pageNumber <= maxPages && len(out) < limit; pageNumber++ {
		historyPage, err := c.Page(ctx, pageNumber, pageSize)
		if err != nil {
			return nil, err
		}
		for _, session := range historyPage.Data {
			if strings.EqualFold(strings.TrimSpace(session.User.Username), wanted) {
				out = append(out, session)
				if len(out) == limit {
					break
				}
			}
		}
		if pageNumber*pageSize >= historyPage.Meta.Total || len(historyPage.Data) == 0 {
			break
		}
	}
	return out, nil
}

// Page reads one public-history page. Imports use this directly so Tracearr pagination
// happens in the background rather than while a television waits.
func (c *Client) Page(ctx context.Context, pageNumber, pageSize int) (Page, error) {
	endpoint, err := c.publicEndpoint("/api/v1/public/history", pageNumber, pageSize)
	if err != nil {
		return Page{}, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
	if err != nil {
		return Page{}, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Accept", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return Page{}, fmt.Errorf("tracearr history: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		return Page{}, fmt.Errorf("tracearr history: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
	}
	var result Page
	if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&result); err != nil {
		return Page{}, fmt.Errorf("tracearr history: decode: %w", err)
	}
	return result, nil
}

// Users lists the Tracearr identities known to the configured media server. Household
// preparation uses exact username matches against Emby; no fuzzy identity guesses are
// made here.
func (c *Client) Users(ctx context.Context, pageNumber, pageSize int) (UserPage, error) {
	endpoint, err := c.publicEndpoint("/api/v1/public/users", pageNumber, pageSize)
	if err != nil {
		return UserPage{}, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
	if err != nil {
		return UserPage{}, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Accept", "application/json")
	resp, err := c.http.Do(req)
	if err != nil {
		return UserPage{}, fmt.Errorf("tracearr users: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		return UserPage{}, fmt.Errorf(
			"tracearr users: status %d: %s",
			resp.StatusCode, strings.TrimSpace(string(body)),
		)
	}
	var result UserPage
	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
		return UserPage{}, fmt.Errorf("tracearr users: decode: %w", err)
	}
	return result, nil
}

func (c *Client) publicEndpoint(path string, pageNumber, pageSize int) (*url.URL, error) {
	endpoint, err := url.Parse(c.baseURL + path)
	if err != nil {
		return nil, err
	}
	query := endpoint.Query()
	query.Set("page", strconv.Itoa(pageNumber))
	query.Set("pageSize", strconv.Itoa(pageSize))
	if c.serverID != "" {
		query.Set("serverId", c.serverID)
	}
	endpoint.RawQuery = query.Encode()
	return endpoint, nil
}

func (c *Client) ConfiguredServerID() string {
	if c == nil {
		return ""
	}
	return c.serverID
}

// Completion is the useful fraction of a session. Tracearr's watched flag wins; for an
// interrupted play, progress is preferred and aggregate watch time is the fallback.
func (s Session) Completion() float64 {
	if s.Watched {
		return 1
	}
	if s.TotalDurationMs <= 0 {
		return 0
	}
	progress := s.ProgressMs
	if s.DurationMs > progress {
		progress = s.DurationMs
	}
	value := float64(progress) / float64(s.TotalDurationMs)
	if value < 0 {
		return 0
	}
	if value > 1 {
		return 1
	}
	return value
}

func (s Session) TitleKey() string {
	if strings.EqualFold(s.MediaType, "episode") && strings.TrimSpace(s.ShowTitle) != "" {
		return normalizeTitle(s.ShowTitle)
	}
	return normalizeTitle(s.MediaTitle)
}

func (s Session) IsTelevisionSession() bool {
	value := strings.ToLower(strings.Join([]string{s.Device, s.Player, s.Product, s.Platform}, " "))
	return strings.Contains(value, "tv") ||
		strings.Contains(value, "android") ||
		strings.Contains(value, "memby")
}

func normalizeTitle(value string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(value) {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			b.WriteRune(r)
		}
	}
	return b.String()
}
