// Package tags enriches the taste profile's tag facet beyond raw ID3
// genre (milestone #160, task #1490). It fetches folksonomy style/mood
// tags for tracks from a chain of pluggable providers (MusicBrainz,
// Last.fm, …), caches them in track_tags, and the taste recompute unions
// them alongside tracks.genre.
//
// The provider model mirrors internal/coverart deliberately: a source is
// added by implementing TrackTagProvider and calling Register() in the
// provider file's init() — no enricher, settings, or schema change. The
// DB-backed SettingsService then auto-upserts a settings row for it, and
// the boot-time provider-hash bump makes previously-settled rows eligible
// for a retry through the widened chain.
package tags

import (
	"context"
	"errors"
	"fmt"
	"sync"

	"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
)

// Tag is one folksonomy tag with a normalized strength in [0,1]. Each
// provider normalizes its own upstream weighting (Last.fm's 0-100 count,
// MusicBrainz's vote count) into this range so the enricher can merge
// across providers on a common scale.
type Tag struct {
	Name   string
	Weight float64
}

// TrackRef is the lookup key passed to a provider. MBID is the track's
// recording MBID (preferred by MBID-keyed providers); ArtistName + Title
// are the always-populated fallback for name-based providers (Last.fm);
// ArtistMBID (nullable) enables MusicBrainz's artist-tag fallback (#1519)
// when the recording itself is untagged or MBID-less.
type TrackRef struct {
	MBID       string
	ArtistName string
	Title      string
	ArtistMBID string
}

// Provider is the base shape every tag source implements. Providers
// register at init() via Register(). The SettingsService reads the
// per-provider row (enabled, api_key) and calls Configure().
type Provider interface {
	// ID is a stable lowercase token persisted in tracks.tag_source and
	// keyed in tag_provider_settings.provider_id, and shown in the admin UI.
	ID() string

	// DisplayName is the human-readable label for the admin Settings card.
	DisplayName() string

	// RequiresAPIKey reports whether the provider needs a key to function.
	// The admin UI shows/hides the key field on this; it does NOT gate the
	// enabled toggle (rule #26 — providers with usable defaults ship working).
	RequiresAPIKey() bool

	// DefaultEnabled is whether a freshly-installed Minstrel ships with
	// this provider on. MusicBrainz (keyless) returns true; Last.fm
	// (keyed) returns false — opt-in once a key is supplied.
	DefaultEnabled() bool

	// Configure applies the operator's settings. Called at boot and on
	// every admin PATCH; must be idempotent and goroutine-safe (the
	// enricher may read concurrently).
	Configure(ProviderSettings) error
}

// ProviderSettings is the runtime config a provider receives on Configure.
type ProviderSettings struct {
	Enabled bool
	APIKey  string
}

// TrackTagProvider is the tag-fetch capability. Split from Provider so a
// future source with a different capability (e.g. artist-level tags) can
// be added without widening this one.
type TrackTagProvider interface {
	Provider
	// FetchTrackTags returns the folksonomy tags for the track, or
	// ErrNotFound (terminal — nothing upstream) / ErrTransient (retry).
	// A disabled provider, or one missing a required key, returns
	// ErrNotFound so the chain simply skips it.
	FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error)
}

// ArtistRef is the lookup key for artist-level tags. MBID is the artist's
// MusicBrainz ID (required by MBID-keyed providers); Name is the fallback for
// name-based providers (Last.fm). At least one must be set or every provider
// returns ErrNotFound.
type ArtistRef struct {
	MBID string
	Name string
}

// ArtistTagProvider is the artist-level tag capability, added for tag-space
// Discover (#2376). Separate from TrackTagProvider — exactly the split that
// interface's doc comment anticipated — so a source can implement either
// without the other, and so the enricher can ask for the capability it needs
// rather than checking at the call site.
//
// The subject here is an artist Minstrel does NOT have locally, so there is no
// track to fall back to and no recording-level tag to prefer.
type ArtistTagProvider interface {
	Provider
	// FetchArtistTags returns the artist's folksonomy tags, or ErrNotFound
	// (terminal — nothing upstream) / ErrTransient (retry). A disabled
	// provider, or one missing a required key, returns ErrNotFound so the
	// chain simply skips it.
	FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error)
}

// TestableProvider is an opt-in capability for the admin Test-Connection
// button: answer "is my config working?" without a full enrichment cycle.
type TestableProvider interface {
	Provider
	TestConnection(ctx context.Context) error
}

// ErrNotFound: the provider confirmed it has no tags for this track — a
// terminal-this-pass result. The enricher moves to the next provider; if
// every provider returns ErrNotFound the row settles to tag_source='none'.
// Aliased to apierror.ErrNotFound for consistency with the rest of the app.
var ErrNotFound = apierror.ErrNotFound

// ErrTransient: a temporary failure (5xx, network, timeout, auth). The
// enricher leaves the row's tag_source NULL so the next pass retries.
var ErrTransient = errors.New("tags: transient error")

// ErrProviderNotFound is returned when a named provider is not registered.
var ErrProviderNotFound = errors.New("tags: provider not registered")

// ErrNotTestable is returned when a provider doesn't implement
// TestableProvider.
var ErrNotTestable = errors.New("tags: provider does not support test connection")

// registry holds all compiled-in providers. Providers register at init()
// via Register(); the enricher iterates this list filtered by settings.
var registry = struct {
	mu        sync.Mutex
	providers []Provider
}{}

// Register adds a provider. Must be called from a provider file's init().
// Duplicate IDs panic — providers are compiled-in, so it's a build bug.
func Register(p Provider) {
	registry.mu.Lock()
	defer registry.mu.Unlock()
	for _, existing := range registry.providers {
		if existing.ID() == p.ID() {
			panic(fmt.Sprintf("tags: duplicate provider ID %q", p.ID()))
		}
	}
	registry.providers = append(registry.providers, p)
}

// AllProviders returns a snapshot in registration order. Do not mutate.
func AllProviders() []Provider {
	registry.mu.Lock()
	defer registry.mu.Unlock()
	out := make([]Provider, len(registry.providers))
	copy(out, registry.providers)
	return out
}

// ProviderByID looks up a registered provider. Returns ErrProviderNotFound
// if none matches.
func ProviderByID(id string) (Provider, error) {
	registry.mu.Lock()
	defer registry.mu.Unlock()
	for _, p := range registry.providers {
		if p.ID() == id {
			return p, nil
		}
	}
	return nil, ErrProviderNotFound
}

// ResetRegistryForTests clears the registry. Test-only.
func ResetRegistryForTests() {
	registry.mu.Lock()
	defer registry.mu.Unlock()
	registry.providers = nil
}
