package tags

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

// mbBaseURL is a var (not const) so tests can point it at an httptest server.
var mbBaseURL = "https://musicbrainz.org/ws/2"

const (
	// mbMinPeriod honors MusicBrainz's 1 req/s anonymous ceiling (with headroom).
	mbMinPeriod = 1100 * time.Millisecond
	// mbUserAgent identifies the app — MusicBrainz rejects requests without one.
	mbUserAgent = "Minstrel/1.0 ( https://git.fabledsword.com/bvandeusen/minstrel )"
	// artistTagWeightFactor down-weights artist-level fallback tags relative
	// to recording-specific tags — the artist's overall character is a coarser
	// signal than a tag on the exact recording (#1519).
	artistTagWeightFactor = 0.6
)

// musicbrainzProvider fetches folksonomy tags from the MusicBrainz web
// service. Keyless (the public endpoint needs no auth) and on by default —
// the always-available baseline source. Prefers recording-level tags by the
// track's recording MBID; when the recording is untagged or MBID-less it
// falls back to the artist's tags by artist MBID (#1519), down-weighted as a
// coarser signal. Name-based lookup is deferred to providers like Last.fm.
type musicbrainzProvider struct {
	enabled atomic.Bool
	client  *httpClient
}

func init() {
	Register(&musicbrainzProvider{
		client: newHTTPClient(httpClientOptions{
			Name:        "musicbrainz",
			UserAgent:   mbUserAgent,
			HTTPClient:  &http.Client{Timeout: 30 * time.Second},
			MinInterval: mbMinPeriod,
			MaxRetries:  3,
		}),
	})
}

func (p *musicbrainzProvider) ID() string           { return "musicbrainz" }
func (p *musicbrainzProvider) DisplayName() string  { return "MusicBrainz" }
func (p *musicbrainzProvider) RequiresAPIKey() bool { return false }
func (p *musicbrainzProvider) DefaultEnabled() bool { return true }

func (p *musicbrainzProvider) Configure(s ProviderSettings) error {
	p.enabled.Store(s.Enabled)
	return nil
}

// mbTagsResponse models the subset of /ws/2/recording/{mbid}?inc=tags we read.
type mbTagsResponse struct {
	Tags []mbTag `json:"tags"`
}

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

// FetchTrackTags looks up recording tags by MBID, then falls back to artist
// tags by artist MBID when the recording is untagged or MBID-less. Returns
// ErrNotFound when disabled or when neither level yields tags. A transient
// error at the recording step is returned as-is (retry) rather than silently
// masked by the artist fallback.
func (p *musicbrainzProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
	if !p.enabled.Load() {
		return nil, ErrNotFound
	}
	if ref.MBID != "" {
		tags, err := p.fetchEntityTags(ctx, "recording", ref.MBID, 1.0)
		if err != nil && !errors.Is(err, ErrNotFound) {
			return nil, err
		}
		if len(tags) > 0 {
			return tags, nil
		}
	}
	if ref.ArtistMBID != "" {
		tags, err := p.fetchEntityTags(ctx, "artist", ref.ArtistMBID, artistTagWeightFactor)
		if err != nil && !errors.Is(err, ErrNotFound) {
			return nil, err
		}
		if len(tags) > 0 {
			return tags, nil
		}
	}
	return nil, ErrNotFound
}

// FetchArtistTags looks up an artist's own tags by MBID (#2376). MBID-only:
// MusicBrainz has no name-based tag lookup worth trusting for this, and a
// wrong-artist match would poison the tag cache silently.
//
// Note the scale is 1.0, NOT artistTagWeightFactor. That discount exists
// because FetchTrackTags uses artist tags as a *proxy* for a track's tags, and
// the artist's overall character is the coarser signal of the two. Here the
// artist IS the subject, so there is nothing to discount relative to — and
// applying it would make these weights incomparable with track_tags, which is
// exactly the comparison slice 6 depends on.
func (p *musicbrainzProvider) FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error) {
	if !p.enabled.Load() || ref.MBID == "" {
		return nil, ErrNotFound
	}
	tags, err := p.fetchEntityTags(ctx, "artist", ref.MBID, 1.0)
	if err != nil {
		return nil, err
	}
	// fetchEntityTags reports an existing-but-untagged entity as (empty, nil)
	// so FetchTrackTags can fall through to the next level. There is no next
	// level here, so empty is the terminal "nothing upstream".
	if len(tags) == 0 {
		return nil, ErrNotFound
	}
	return tags, nil
}

// fetchEntityTags loads folksonomy tags for a MusicBrainz entity ("recording"
// or "artist") by MBID and scales the normalized weights by `scale`. Returns
// an empty slice (not ErrNotFound) when the entity exists but is untagged, so
// the caller can decide whether to fall through to the next level.
func (p *musicbrainzProvider) fetchEntityTags(ctx context.Context, entity, mbid string, scale float64) ([]Tag, error) {
	q := url.Values{"inc": {"tags"}, "fmt": {"json"}}
	full := mbBaseURL + "/" + entity + "/" + url.PathEscape(mbid) + "?" + q.Encode()
	var resp mbTagsResponse
	if err := p.client.getJSON(ctx, full, &resp); err != nil {
		return nil, err
	}
	tags := normalizeMBTags(resp.Tags)
	if scale != 1.0 {
		for i := range tags {
			tags[i].Weight *= scale
		}
	}
	return tags, nil
}

// TestConnection issues a tiny search request; any non-transient response
// means MusicBrainz is reachable.
func (p *musicbrainzProvider) TestConnection(ctx context.Context) error {
	full := mbBaseURL + "/recording?query=*&limit=1&fmt=json"
	var discard map[string]any
	err := p.client.getJSON(ctx, full, &discard)
	if err == nil || errors.Is(err, ErrNotFound) {
		return nil
	}
	return err
}

// normalizeMBTags drops non-positive-vote tags (net-downvoted or zero) and
// scales the remaining vote counts into [0,1] relative to the strongest tag
// on this recording, so one recording's raw counts don't dominate another's.
func normalizeMBTags(raw []mbTag) []Tag {
	maxCount := 0
	for _, t := range raw {
		if t.Count > maxCount {
			maxCount = t.Count
		}
	}
	if maxCount == 0 {
		return nil
	}
	out := make([]Tag, 0, len(raw))
	for _, t := range raw {
		if t.Count <= 0 || t.Name == "" {
			continue
		}
		out = append(out, Tag{Name: t.Name, Weight: float64(t.Count) / float64(maxCount)})
	}
	return out
}

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