// Package listenbrainz is the pure HTTP client for the ListenBrainz
// submit-listens endpoint. No DB, no worker plumbing — just types and one
// method. Errors are typed (ErrAuth, ErrPermanent, ErrTransient,
// *RetryAfterError) so callers can branch on response semantics.
package listenbrainz

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"time"
)

const defaultBaseURL = "https://api.listenbrainz.org"

// defaultLabsBaseURL is the ListenBrainz *Labs* API — a DIFFERENT host
// from the main API. Similarity datasets (similar-recordings /
// similar-artists) live here, not under api.listenbrainz.org. The old
// api.listenbrainz.org/1/explore/... paths are website routes, not API
// endpoints, and 404 for every request.
const defaultLabsBaseURL = "https://labs.api.listenbrainz.org"

// Listen is one row sent to ListenBrainz.
type Listen struct {
	ListenedAt int64 // unix seconds
	Track      Track
}

// Track is the per-listen metadata. Optional fields are omitted from the
// payload when zero-valued.
type Track struct {
	ArtistName    string
	TrackName     string
	ReleaseName   string
	DurationMs    int
	RecordingMBID string
	ArtistMBIDs   []string
	ReleaseMBID   string
}

// Client posts to the LB submit-listens endpoint and queries the Labs
// API for similarity. BaseURL is the main API; LabsBaseURL is the
// separate Labs host (see defaultLabsBaseURL).
type Client struct {
	BaseURL     string
	LabsBaseURL string
	HTTP        *http.Client
}

// NewClient returns a default-configured client (LB production base
// URLs, 30s timeout).
func NewClient() *Client {
	return &Client{
		BaseURL:     defaultBaseURL,
		LabsBaseURL: defaultLabsBaseURL,
		HTTP:        &http.Client{Timeout: 30 * time.Second},
	}
}

// Sentinel errors. The worker branches on these to decide retry vs fail.
var (
	ErrAuth      = errors.New("listenbrainz: auth rejected")
	ErrPermanent = errors.New("listenbrainz: permanent error")
	ErrTransient = errors.New("listenbrainz: transient error")
)

// RetryAfterError carries the LB-supplied wait duration for 429 responses.
type RetryAfterError struct{ Wait time.Duration }

func (e *RetryAfterError) Error() string {
	return fmt.Sprintf("listenbrainz: retry after %v", e.Wait)
}

// SubmitListens posts the given listens. payload_type is "single" for one
// listen, "import" for batches.
func (c *Client) SubmitListens(ctx context.Context, token string, listens []Listen) error {
	if len(listens) == 0 {
		return nil
	}

	listenType := "import"
	if len(listens) == 1 {
		listenType = "single"
	}

	body, err := json.Marshal(buildPayload(listenType, listens))
	if err != nil {
		return fmt.Errorf("listenbrainz: marshal: %w", err)
	}

	base := c.BaseURL
	if base == "" {
		base = defaultBaseURL
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/1/submit-listens", bytes.NewReader(body))
	if err != nil {
		return fmt.Errorf("listenbrainz: build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Token "+token)

	httpClient := c.HTTP
	if httpClient == nil {
		httpClient = http.DefaultClient
	}
	resp, err := httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrTransient, err)
	}
	defer func() { _ = resp.Body.Close() }()

	switch {
	case resp.StatusCode >= 200 && resp.StatusCode < 300:
		return nil
	case resp.StatusCode == http.StatusUnauthorized:
		return ErrAuth
	case resp.StatusCode == http.StatusTooManyRequests:
		wait := parseRetryAfter(resp.Header.Get("Retry-After"))
		return &RetryAfterError{Wait: wait}
	case resp.StatusCode >= 500:
		return fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
	default:
		return fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode)
	}
}

func parseRetryAfter(header string) time.Duration {
	if header == "" {
		return 60 * time.Second // sensible default
	}
	if secs, err := strconv.Atoi(header); err == nil {
		return time.Duration(secs) * time.Second
	}
	if t, err := http.ParseTime(header); err == nil {
		d := time.Until(t)
		if d < 0 {
			return 60 * time.Second
		}
		return d
	}
	return 60 * time.Second
}

// payload mirrors the LB submit-listens body. Optional MBID fields use
// `omitempty` so empty strings/slices don't appear in the JSON.
type payload struct {
	ListenType string         `json:"listen_type"`
	Payload    []payloadEntry `json:"payload"`
}

type payloadEntry struct {
	ListenedAt    int64         `json:"listened_at"`
	TrackMetadata trackMetadata `json:"track_metadata"`
}

type trackMetadata struct {
	ArtistName     string         `json:"artist_name"`
	TrackName      string         `json:"track_name"`
	ReleaseName    string         `json:"release_name,omitempty"`
	AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
}

type additionalInfo struct {
	DurationMs    int      `json:"duration_ms,omitempty"`
	RecordingMBID string   `json:"recording_mbid,omitempty"`
	ArtistMBIDs   []string `json:"artist_mbids,omitempty"`
	ReleaseMBID   string   `json:"release_mbid,omitempty"`
}

func buildPayload(listenType string, listens []Listen) payload {
	entries := make([]payloadEntry, 0, len(listens))
	for _, l := range listens {
		entries = append(entries, payloadEntry{
			ListenedAt: l.ListenedAt,
			TrackMetadata: trackMetadata{
				ArtistName:  l.Track.ArtistName,
				TrackName:   l.Track.TrackName,
				ReleaseName: l.Track.ReleaseName,
				AdditionalInfo: additionalInfo{
					DurationMs:    l.Track.DurationMs,
					RecordingMBID: l.Track.RecordingMBID,
					ArtistMBIDs:   l.Track.ArtistMBIDs,
					ReleaseMBID:   l.Track.ReleaseMBID,
				},
			},
		})
	}
	return payload{ListenType: listenType, Payload: entries}
}

// lbSimilarRecordingsAlgorithm is a Labs-API-valid algorithm enum (the
// value is verified against labs.api.listenbrainz.org; the old
// "_session_30_…_limit_100_filter_True…" string is NOT a permitted
// member and 400s). limit_50 is baked into the name — there is no
// separate count/limit query param.
const lbSimilarRecordingsAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"

// SimilarRecording is one entry in the Labs similar-recordings response.
type SimilarRecording struct {
	MBID  string  `json:"recording_mbid"`
	Score float64 `json:"score"`
}

// SimilarRecordings fetches similar recordings for the given recording
// MBID from the ListenBrainz Labs API. Public endpoint — no token
// required. The result count is fixed by the algorithm (limit_50); the
// caller applies its own top-K. Returns the same typed errors as
// SubmitListens.
func (c *Client) SimilarRecordings(ctx context.Context, mbid string, _ int) ([]SimilarRecording, error) {
	base := c.LabsBaseURL
	if base == "" {
		base = defaultLabsBaseURL
	}
	url := fmt.Sprintf("%s/similar-recordings/json?recording_mbids=%s&algorithm=%s",
		base, mbid, lbSimilarRecordingsAlgorithm)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("listenbrainz: build similar-recordings request: %w", err)
	}

	httpClient := c.HTTP
	if httpClient == nil {
		httpClient = http.DefaultClient
	}
	resp, err := httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrTransient, err)
	}
	defer func() { _ = resp.Body.Close() }()

	switch {
	case resp.StatusCode >= 200 && resp.StatusCode < 300:
		var out []SimilarRecording
		if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil {
			return nil, fmt.Errorf("%w: decode similar-recordings: %v", ErrTransient, derr)
		}
		return out, nil
	case resp.StatusCode == http.StatusUnauthorized:
		return nil, ErrAuth
	case resp.StatusCode == http.StatusTooManyRequests:
		return nil, &RetryAfterError{Wait: parseRetryAfter(resp.Header.Get("Retry-After"))}
	case resp.StatusCode >= 500:
		return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
	default:
		return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode)
	}
}

// lbSimilarArtistsAlgorithm is a Labs-API-valid algorithm enum (verified
// against labs.api.listenbrainz.org; shares the same permitted set as
// similar-recordings). The old value 400s. limit_50 is baked in — no
// separate count/limit query param.
const lbSimilarArtistsAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"

// SimilarArtist is one entry in the Labs similar-artists response. Name
// is captured from the LB payload so M5c can render the artist's name
// on out-of-library suggestions without an extra MusicBrainz lookup.
// (The Labs payload also carries comment/type/gender/reference_mbid;
// those are intentionally ignored.)
type SimilarArtist struct {
	MBID  string  `json:"artist_mbid"`
	Name  string  `json:"name"`
	Score float64 `json:"score"`
}

// SimilarArtists fetches similar artists for the given artist MBID from
// the ListenBrainz Labs API. Public endpoint — no token required. The
// result count is fixed by the algorithm (limit_50); the caller applies
// its own top-K. Returns the same typed errors as SubmitListens.
func (c *Client) SimilarArtists(ctx context.Context, mbid string, _ int) ([]SimilarArtist, error) {
	base := c.LabsBaseURL
	if base == "" {
		base = defaultLabsBaseURL
	}
	url := fmt.Sprintf("%s/similar-artists/json?artist_mbids=%s&algorithm=%s",
		base, mbid, lbSimilarArtistsAlgorithm)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("listenbrainz: build similar-artists request: %w", err)
	}

	httpClient := c.HTTP
	if httpClient == nil {
		httpClient = http.DefaultClient
	}
	resp, err := httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrTransient, err)
	}
	defer func() { _ = resp.Body.Close() }()

	switch {
	case resp.StatusCode >= 200 && resp.StatusCode < 300:
		var out []SimilarArtist
		if derr := json.NewDecoder(resp.Body).Decode(&out); derr != nil {
			return nil, fmt.Errorf("%w: decode similar-artists: %v", ErrTransient, derr)
		}
		return out, nil
	case resp.StatusCode == http.StatusUnauthorized:
		return nil, ErrAuth
	case resp.StatusCode == http.StatusTooManyRequests:
		return nil, &RetryAfterError{Wait: parseRetryAfter(resp.Header.Get("Retry-After"))}
	case resp.StatusCode >= 500:
		return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
	default:
		return nil, fmt.Errorf("%w: status %d", ErrPermanent, resp.StatusCode)
	}
}
