package tags

import (
	"context"
	"errors"
	"net/http"
	"net/url"
	"sync/atomic"
	"time"
)

// lastfmBaseURL is a var (not const) so tests can override it.
var lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/"

const (
	// lastfmMinPeriod throttles to 4 req/s under the 5/s per-key ceiling.
	lastfmMinPeriod = 250 * time.Millisecond
	// lastfmTagMaxCount is Last.fm's top-tag count scale (0-100 popularity).
	lastfmTagMaxCount = 100.0
)

// lastfmTransientErrors are Last.fm JSON-level error codes that mean
// "try again" rather than "no data": 8 operation failed, 11/16 service
// temporarily unavailable, 29 rate limit. Everything else non-zero
// (6 not found, 10 invalid key, …) is treated as ErrNotFound so the
// chain simply skips this provider for the track.
var lastfmTransientErrors = map[int]bool{8: true, 11: true, 16: true, 29: true}

// lastfmProvider fetches track-level folksonomy tags via Last.fm's
// track.getTopTags. Requires an operator-supplied API key; stays inert
// (ErrNotFound on every call) when disabled or unkeyed. Name-based, so it
// covers the many local tracks that lack a recording MBID.
type lastfmProvider struct {
	enabled atomic.Bool
	apiKey  atomic.Pointer[string]
	client  *httpClient
}

func init() {
	Register(&lastfmProvider{
		client: newHTTPClient(httpClientOptions{
			Name:                 "lastfm",
			HTTPClient:           &http.Client{Timeout: 30 * time.Second},
			MinInterval:          lastfmMinPeriod,
			MaxRetries:           3,
			TreatAuthAsTransient: true,
		}),
	})
}

func (p *lastfmProvider) ID() string           { return "lastfm" }
func (p *lastfmProvider) DisplayName() string  { return "Last.fm" }
func (p *lastfmProvider) RequiresAPIKey() bool { return true }
func (p *lastfmProvider) DefaultEnabled() bool { return false }

func (p *lastfmProvider) Configure(s ProviderSettings) error {
	p.enabled.Store(s.Enabled)
	key := s.APIKey
	p.apiKey.Store(&key)
	return nil
}

func (p *lastfmProvider) currentKey() string {
	if k := p.apiKey.Load(); k != nil {
		return *k
	}
	return ""
}

// lastfmTopTags models the subset of track.getTopTags we read. Last.fm
// returns `error` on failure responses instead of the toptags block.
type lastfmTopTags struct {
	TopTags struct {
		Tag []lastfmTag `json:"tag"`
	} `json:"toptags"`
	Error int `json:"error"`
}

type lastfmTag struct {
	Name  string `json:"name"`
	Count int    `json:"count"`
}

// FetchTrackTags looks up the track's top tags by artist + title (with the
// recording MBID as an extra hint when present). Returns ErrNotFound when
// disabled, unkeyed, missing artist/title, or when Last.fm has no tags.
func (p *lastfmProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
	if !p.enabled.Load() || p.currentKey() == "" {
		return nil, ErrNotFound
	}
	if ref.ArtistName == "" || ref.Title == "" {
		return nil, ErrNotFound
	}

	q := url.Values{
		"method":      {"track.gettoptags"},
		"api_key":     {p.currentKey()},
		"format":      {"json"},
		"artist":      {ref.ArtistName},
		"track":       {ref.Title},
		"autocorrect": {"1"},
	}
	if ref.MBID != "" {
		q.Set("mbid", ref.MBID)
	}

	var resp lastfmTopTags
	if err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp); err != nil {
		return nil, err
	}
	if resp.Error != 0 {
		if lastfmTransientErrors[resp.Error] {
			return nil, ErrTransient
		}
		return nil, ErrNotFound
	}
	out := normalizeLastfmTags(resp.TopTags.Tag)
	if len(out) == 0 {
		return nil, ErrNotFound
	}
	return out, nil
}

// FetchArtistTags looks up an artist's top tags by name, with the MBID as an
// extra hint when present (#2376). Name-first is deliberate and the opposite
// emphasis from MusicBrainz: Last.fm's tag data is keyed on its own artist
// pages, and autocorrect resolves most spelling drift from the similarity feed.
//
// `artist.getTopTags` returns the same `toptags` envelope as
// `track.getTopTags`, so the response type and normalizer are reused as-is —
// the 0-100 popularity scale is identical.
func (p *lastfmProvider) FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error) {
	if !p.enabled.Load() || p.currentKey() == "" {
		return nil, ErrNotFound
	}
	if ref.Name == "" {
		return nil, ErrNotFound
	}

	q := url.Values{
		"method":      {"artist.gettoptags"},
		"api_key":     {p.currentKey()},
		"format":      {"json"},
		"artist":      {ref.Name},
		"autocorrect": {"1"},
	}
	if ref.MBID != "" {
		q.Set("mbid", ref.MBID)
	}

	var resp lastfmTopTags
	if err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp); err != nil {
		return nil, err
	}
	if resp.Error != 0 {
		if lastfmTransientErrors[resp.Error] {
			return nil, ErrTransient
		}
		return nil, ErrNotFound
	}
	out := normalizeLastfmTags(resp.TopTags.Tag)
	if len(out) == 0 {
		return nil, ErrNotFound
	}
	return out, nil
}

// TestConnection verifies the key against a well-known track.
func (p *lastfmProvider) TestConnection(ctx context.Context) error {
	if p.currentKey() == "" {
		return errors.New("tags: lastfm api key not set")
	}
	q := url.Values{
		"method":  {"track.gettoptags"},
		"api_key": {p.currentKey()},
		"format":  {"json"},
		"artist":  {"Radiohead"},
		"track":   {"Creep"},
	}
	var resp lastfmTopTags
	err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp)
	if err != nil && !errors.Is(err, ErrNotFound) {
		return err
	}
	if resp.Error == 10 { // invalid API key
		return errors.New("tags: lastfm rejected the api key")
	}
	return nil
}

// normalizeLastfmTags scales the 0-100 popularity counts into [0,1] and
// drops zero-count / unnamed tags.
func normalizeLastfmTags(raw []lastfmTag) []Tag {
	out := make([]Tag, 0, len(raw))
	for _, t := range raw {
		if t.Count <= 0 || t.Name == "" {
			continue
		}
		w := float64(t.Count) / lastfmTagMaxCount
		if w > 1 {
			w = 1
		}
		out = append(out, Tag{Name: t.Name, Weight: w})
	}
	return out
}

// Compile-time capability checks.
var (
	_ TrackTagProvider  = (*lastfmProvider)(nil)
	_ ArtistTagProvider = (*lastfmProvider)(nil)
	_ TestableProvider  = (*lastfmProvider)(nil)
)
