package tags

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"testing"
)

// runChain is the merge-and-classify loop shared by the track and
// candidate-artist drains (#2376). It touches no DB, so it is testable with a
// bare Enricher.
//
// The classification is the part that matters: "every source cleanly had
// nothing" and "a source failed" lead to opposite persistence decisions
// (settle vs. leave eligible for retry), and conflating them either writes off
// an artist over a transient blip or re-fetches a genuinely untagged one
// forever.

func chainEnricher() *Enricher {
	return &Enricher{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
}

func fixedFetch(tags []Tag, err error) func(context.Context) ([]Tag, error) {
	return func(context.Context) ([]Tag, error) { return tags, err }
}

func TestRunChain_UnionsAcrossProvidersMaxWeightWins(t *testing.T) {
	res := chainEnricher().runChain(context.Background(), []tagFetch{
		{providerID: "musicbrainz", fetch: fixedFetch([]Tag{
			{Name: "shoegaze", Weight: 0.4},
			{Name: "noise", Weight: 0.9},
		}, nil)},
		{providerID: "lastfm", fetch: fixedFetch([]Tag{
			{Name: "shoegaze", Weight: 0.8}, // higher — should win
			{Name: "dream pop", Weight: 0.3},
		}, nil)},
	}, "subject", "x")

	if got := res.merged["shoegaze"]; got != 0.8 {
		t.Errorf("shoegaze = %v, want 0.8 (max across providers)", got)
	}
	if got := res.merged["noise"]; got != 0.9 {
		t.Errorf("noise = %v, want 0.9", got)
	}
	if got := res.merged["dream pop"]; got != 0.3 {
		t.Errorf("dream pop = %v, want 0.3", got)
	}
	if len(res.merged) != 3 {
		t.Errorf("merged has %d tags, want 3: %v", len(res.merged), res.merged)
	}
	if res.anyTransient {
		t.Error("anyTransient set with no failures")
	}
	if len(res.contributors) != 2 {
		t.Errorf("contributors = %v, want both providers", res.contributors)
	}
}

// A lower weight arriving second must not overwrite a higher one — the
// ordering of the chain must not change the result.
func TestRunChain_LowerWeightSecondDoesNotClobber(t *testing.T) {
	res := chainEnricher().runChain(context.Background(), []tagFetch{
		{providerID: "a", fetch: fixedFetch([]Tag{{Name: "rock", Weight: 0.9}}, nil)},
		{providerID: "b", fetch: fixedFetch([]Tag{{Name: "rock", Weight: 0.2}}, nil)},
	}, "subject", "x")
	if got := res.merged["rock"]; got != 0.9 {
		t.Errorf("rock = %v, want 0.9", got)
	}
}

func TestRunChain_NotFoundIsSkippedNotTransient(t *testing.T) {
	res := chainEnricher().runChain(context.Background(), []tagFetch{
		{providerID: "a", fetch: fixedFetch(nil, ErrNotFound)},
		{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
	}, "subject", "x")

	if res.anyTransient {
		t.Error("ErrNotFound must not be treated as transient — it would keep a settled subject eligible forever")
	}
	if len(res.contributors) != 1 || res.contributors[0] != "b" {
		t.Errorf("contributors = %v, want [b] only (a returned nothing)", res.contributors)
	}
}

func TestRunChain_TransientIsRecordedEvenWhenAnotherProviderSucceeds(t *testing.T) {
	res := chainEnricher().runChain(context.Background(), []tagFetch{
		{providerID: "a", fetch: fixedFetch(nil, ErrTransient)},
		{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
	}, "subject", "x")

	if !res.anyTransient {
		t.Error("anyTransient should be set — 'a' may have had tags we never saw")
	}
	// Tags DID land, so the caller writes them; anyTransient only decides the
	// no-tags case. Asserting both here pins that they're independent.
	if len(res.merged) != 1 {
		t.Errorf("merged = %v, want folk", res.merged)
	}
}

// An unexpected error type is transient, not terminal. Defaulting the other way
// would settle a subject on any bug in a provider.
func TestRunChain_UnknownErrorIsTransient(t *testing.T) {
	res := chainEnricher().runChain(context.Background(), []tagFetch{
		{providerID: "a", fetch: fixedFetch(nil, errors.New("boom"))},
	}, "subject", "x")
	if !res.anyTransient {
		t.Error("unknown error should count as transient")
	}
	if len(res.merged) != 0 {
		t.Errorf("merged = %v, want empty", res.merged)
	}
}

// A provider returning (empty, nil) is not a contributor: sourceLabel would
// otherwise stamp its ID onto a subject it gave nothing to.
func TestRunChain_EmptySuccessIsNotAContributor(t *testing.T) {
	res := chainEnricher().runChain(context.Background(), []tagFetch{
		{providerID: "a", fetch: fixedFetch(nil, nil)},
		{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
	}, "subject", "x")
	if len(res.contributors) != 1 || res.contributors[0] != "b" {
		t.Errorf("contributors = %v, want [b]", res.contributors)
	}
}

// No enabled providers is a clean "nothing found", NOT a failure — the caller
// settles the subject rather than retrying an empty chain on every tick.
func TestRunChain_EmptyChainSettlesRatherThanRetries(t *testing.T) {
	res := chainEnricher().runChain(context.Background(), nil, "subject", "x")
	if res.anyTransient {
		t.Error("an empty chain must not look transient")
	}
	if len(res.merged) != 0 || len(res.contributors) != 0 {
		t.Errorf("empty chain produced %v / %v", res.merged, res.contributors)
	}
	if sourceLabel(res.contributors) != sourceNone {
		t.Errorf("sourceLabel = %q, want %q", sourceLabel(res.contributors), sourceNone)
	}
}
