package tags

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"sort"

	"github.com/jackc/pgx/v5/pgtype"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// defaultTopK caps how many tags are cached per track. Enough for a rich
// facet, small enough that a heavily-tagged track can't swamp the taste
// profile when the recompute unions them in.
const defaultTopK = 8

// Source labels stamped onto tracks.tag_source. A single contributing
// provider stamps its own ID; two or more stamp "mixed". Kept in sync with
// the SetTrackTagSource query's documented whitelist.
const (
	sourceNone  = "none"
	sourceMixed = "mixed"
)

// outcome classifies one track's enrichment for the batch tally.
type outcome int

const (
	outcomeEnriched outcome = iota // wrote ≥1 tag
	outcomeNone                    // every provider had nothing → settled 'none'
	outcomeLeftNull                // a transient failure → left NULL for retry
)

// Enricher drains tracks needing tags, fetches from the enabled provider
// chain, MERGES the results (union across providers, unlike coverart's
// first-success-wins for a single image), and caches the top-K into
// track_tags. Owns no pool; receives one via the constructor so the boot
// backfill, post-scan trigger, and admin endpoints share state.
type Enricher struct {
	pool     *pgxpool.Pool
	logger   *slog.Logger
	settings *SettingsService
	topK     int
}

// NewEnricher constructs an Enricher.
func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, settings *SettingsService) *Enricher {
	return &Enricher{pool: pool, logger: logger, settings: settings, topK: defaultTopK}
}

// EnrichTrack runs the chain for one track and persists the merged result.
//
// Every enabled TrackTagProvider is queried and its tags unioned (max
// weight wins on overlap). If any tags surface they're written with the
// source stamped by which providers contributed (musicbrainz / lastfm /
// mixed). If every provider cleanly reports ErrNotFound (or none are
// enabled) the row settles to 'none'. If a provider fails transiently and
// no tags surfaced, the row is left NULL for a next-pass retry.
func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref TrackRef) (outcome, error) {
	providers := e.settings.EnabledTrackTagProviders()
	calls := make([]tagFetch, 0, len(providers))
	for _, provider := range providers {
		calls = append(calls, tagFetch{
			providerID: provider.ID(),
			fetch:      func(c context.Context) ([]Tag, error) { return provider.FetchTrackTags(c, ref) },
		})
	}
	res := e.runChain(ctx, calls, "track_id", uuidString(trackID))

	if len(res.merged) > 0 {
		top := topKByWeight(res.merged, e.topK)
		source := sourceLabel(res.contributors)
		if err := e.writeTags(ctx, trackID, top, source, e.settings.CurrentVersion()); err != nil {
			return outcomeLeftNull, err
		}
		return outcomeEnriched, nil
	}
	if res.anyTransient {
		// Nothing landed but a source may recover — leave NULL for retry.
		return outcomeLeftNull, nil
	}
	// Every provider (if any) had nothing: settle 'none' so we don't
	// re-process until the version bumps (a provider enabled/added).
	if err := e.writeTags(ctx, trackID, nil, sourceNone, e.settings.CurrentVersion()); err != nil {
		return outcomeNone, err
	}
	return outcomeNone, nil
}

// tagFetch pairs a provider ID with a bound fetch call, so the merge-and-
// classify loop below is shared between the track chain and the candidate-
// artist chain (#2376) instead of being written twice with one word changed.
type tagFetch struct {
	providerID string
	fetch      func(context.Context) ([]Tag, error)
}

// chainResult is what running a provider chain produced. contributors is the
// sorted set of provider IDs that actually returned tags — the input to
// sourceLabel.
type chainResult struct {
	merged       map[string]float64
	contributors []string
	anyTransient bool
}

// runChain queries every provider in order and UNIONS the results (max weight
// wins on overlap), unlike coverart's first-success-wins.
//
// A clean ErrNotFound means "this source has nothing" and moves to the next.
// Anything else is transient and recorded, so the caller can leave the row
// eligible for a retry rather than wrongly settling it as "nothing exists" —
// the distinction between those two is the whole point of the return value.
//
// logKey/logVal identify the subject in warnings (a track id or a candidate
// MBID), since this is shared across entity types.
func (e *Enricher) runChain(ctx context.Context, calls []tagFetch, logKey, logVal string) chainResult {
	merged := map[string]float64{}
	contributors := map[string]bool{}
	anyTransient := false

	for _, c := range calls {
		tags, perr := c.fetch(ctx)
		switch {
		case perr == nil:
			for _, t := range tags {
				if t.Weight > merged[t.Name] {
					merged[t.Name] = t.Weight
				}
			}
			if len(tags) > 0 {
				contributors[c.providerID] = true
			}
		case errors.Is(perr, ErrNotFound):
			// Clean "no data from this source" — try the next provider.
		default:
			anyTransient = true
			e.logger.Warn("tags: provider fetch failed; continuing",
				logKey, logVal, "provider", c.providerID, "err", perr)
		}
	}
	return chainResult{
		merged:       merged,
		contributors: contributorIDs(contributors),
		anyTransient: anyTransient,
	}
}

// writeTags atomically replaces a track's cached tags and stamps the source
// + version. tags may be empty (the 'none' settle path), which just clears
// any prior tags and records the outcome.
func (e *Enricher) writeTags(ctx context.Context, trackID pgtype.UUID, tags []Tag, source string, version int32) error {
	tx, err := e.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()
	q := dbq.New(tx)
	if err := q.DeleteTrackTags(ctx, trackID); err != nil {
		return fmt.Errorf("delete track tags: %w", err)
	}
	for _, t := range tags {
		if err := q.InsertTrackTag(ctx, dbq.InsertTrackTagParams{
			TrackID: trackID, Tag: t.Name, Weight: t.Weight,
		}); err != nil {
			return fmt.Errorf("insert track tag: %w", err)
		}
	}
	src := source
	if err := q.SetTrackTagSource(ctx, dbq.SetTrackTagSourceParams{
		ID: trackID, TagSource: &src, TagSourcesVersion: version,
	}); err != nil {
		return fmt.Errorf("set track tag source: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("commit: %w", err)
	}
	return nil
}

// EnrichTrackBatch drains up to limit tracks needing tags and enriches each
// serially. limit: 0 = disabled; >0 = bounded; <0 = unbounded (remote
// providers self-throttle via their httpClient MinInterval). Returns the
// processed/succeeded/failed tally for the scan-run record.
func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
	progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
	if limit == 0 {
		return 0, 0, 0, nil
	}
	queryLimit := int32(limit)
	if limit < 0 {
		queryLimit = 1<<31 - 1
	}
	q := dbq.New(e.pool)
	rows, qerr := q.ListTracksMissingTags(ctx, dbq.ListTracksMissingTagsParams{
		TagSourcesVersion: e.settings.CurrentVersion(),
		Limit:             queryLimit,
	})
	if qerr != nil {
		return 0, 0, 0, fmt.Errorf("list tracks missing tags: %w", qerr)
	}

	var enriched, settledNone, leftNull, errored int
	for _, r := range rows {
		if ctx.Err() != nil {
			e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
			return processed, enriched, settledNone + leftNull + errored, ctx.Err()
		}
		processed++
		ref := TrackRef{ArtistName: r.ArtistName, Title: r.Title}
		if r.Mbid != nil {
			ref.MBID = *r.Mbid
		}
		if r.ArtistMbid != nil {
			ref.ArtistMBID = *r.ArtistMbid
		}
		oc, eerr := e.EnrichTrack(ctx, r.ID, ref)
		if eerr != nil {
			e.logger.Warn("tags: batch entry failed", "track_id", uuidString(r.ID), "err", eerr)
			errored++
		} else {
			switch oc {
			case outcomeEnriched:
				enriched++
			case outcomeNone:
				settledNone++
			case outcomeLeftNull:
				leftNull++
			}
		}
		if progressCb != nil {
			progressCb(processed, enriched, settledNone+leftNull+errored)
		}
	}
	e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
	return processed, enriched, settledNone + leftNull + errored, nil
}

// EnrichCandidateArtist runs the artist-tag chain for one out-of-library
// Discover candidate and caches the merged result (#2376).
//
// Mirrors EnrichTrack, with one deliberate difference in the transient case:
// there is no row to "leave NULL", because eligibility is the ABSENCE of a
// candidate_artist_tag_state row. So a transient failure writes nothing at all,
// which leaves the candidate eligible for the next tick. Writing a state row
// here would settle a candidate whose tags we simply failed to fetch.
func (e *Enricher) EnrichCandidateArtist(ctx context.Context, mbid, name string) (outcome, error) {
	providers := e.settings.EnabledArtistTagProviders()
	ref := ArtistRef{MBID: mbid, Name: name}
	calls := make([]tagFetch, 0, len(providers))
	for _, provider := range providers {
		calls = append(calls, tagFetch{
			providerID: provider.ID(),
			fetch:      func(c context.Context) ([]Tag, error) { return provider.FetchArtistTags(c, ref) },
		})
	}
	res := e.runChain(ctx, calls, "candidate_mbid", mbid)
	version := e.settings.CurrentVersion()

	if len(res.merged) > 0 {
		top := topKByWeight(res.merged, e.topK)
		if err := e.writeCandidateTags(ctx, mbid, top, sourceLabel(res.contributors), version); err != nil {
			return outcomeLeftNull, err
		}
		return outcomeEnriched, nil
	}
	if res.anyTransient {
		return outcomeLeftNull, nil
	}
	if err := e.writeCandidateTags(ctx, mbid, nil, sourceNone, version); err != nil {
		return outcomeNone, err
	}
	return outcomeNone, nil
}

// writeCandidateTags atomically replaces a candidate's cached tags and stamps
// its state. tags may be empty (the 'none' settle path), which clears any prior
// tags and records that the providers had nothing.
func (e *Enricher) writeCandidateTags(
	ctx context.Context, mbid string, tags []Tag, source string, version int32,
) error {
	tx, err := e.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()
	q := dbq.New(tx)
	if err := q.DeleteCandidateArtistTags(ctx, mbid); err != nil {
		return fmt.Errorf("delete candidate artist tags: %w", err)
	}
	for _, t := range tags {
		if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
			CandidateMbid: mbid, Tag: t.Name, Weight: t.Weight,
		}); err != nil {
			return fmt.Errorf("insert candidate artist tag: %w", err)
		}
	}
	if err := q.SetCandidateArtistTagState(ctx, dbq.SetCandidateArtistTagStateParams{
		CandidateMbid:     mbid,
		TagSource:         source,
		TagSourcesVersion: version,
	}); err != nil {
		return fmt.Errorf("set candidate artist tag state: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("commit: %w", err)
	}
	return nil
}

// EnrichCandidateArtistBatch drains up to limit out-of-library candidates and
// enriches each serially. Same limit semantics as EnrichTrackBatch: 0 =
// disabled, >0 = bounded, <0 = unbounded.
//
// The candidate pool is far larger than the track pool (every library artist's
// neighbours) and can never be drained in one pass at MusicBrainz's ~1 req/s,
// so the query hands them back in descending similarity order — see
// ListCandidateArtistsMissingTags. A bounded batch here is therefore normal
// operation, not a degraded mode.
func (e *Enricher) EnrichCandidateArtistBatch(ctx context.Context, limit int) (
	processed, succeeded, failed int, err error,
) {
	if limit == 0 {
		return 0, 0, 0, nil
	}
	queryLimit := int32(limit)
	if limit < 0 {
		queryLimit = 1<<31 - 1
	}
	q := dbq.New(e.pool)
	rows, qerr := q.ListCandidateArtistsMissingTags(ctx, dbq.ListCandidateArtistsMissingTagsParams{
		TagSourcesVersion: e.settings.CurrentVersion(),
		Limit:             queryLimit,
	})
	if qerr != nil {
		return 0, 0, 0, fmt.Errorf("list candidate artists missing tags: %w", qerr)
	}

	var enriched, settledNone, leftNull, errored int
	for _, r := range rows {
		if ctx.Err() != nil {
			e.logCandidateBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
			return processed, enriched, settledNone + leftNull + errored, ctx.Err()
		}
		processed++
		oc, eerr := e.EnrichCandidateArtist(ctx, r.CandidateMbid, r.CandidateName)
		if eerr != nil {
			e.logger.Warn("tags: candidate batch entry failed",
				"candidate_mbid", r.CandidateMbid, "err", eerr)
			errored++
			continue
		}
		switch oc {
		case outcomeEnriched:
			enriched++
		case outcomeNone:
			settledNone++
		case outcomeLeftNull:
			leftNull++
		}
	}
	e.logCandidateBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
	return processed, enriched, settledNone + leftNull + errored, nil
}

// logCandidateBatchSummary mirrors logBatchSummary. `settled_none` is the
// honest-ceiling counter for this surface: candidates whose MBID has no
// upstream tags at all, which no amount of retrying will fix.
func (e *Enricher) logCandidateBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
	e.logger.Info("tags: candidate-artist enrichment batch complete",
		"eligible", eligible,
		"processed", processed,
		"enriched", enriched,
		"settled_none", settledNone,
		"left_null", leftNull,
		"errored", errored)
}

// logBatchSummary emits one Info line with the category breakdown — the
// enriched/settled/left-null split is the operator's diagnostic for a
// "0 enriched" symptom the collapsed tally can't explain.
func (e *Enricher) logBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
	e.logger.Info("tags: enrichment batch complete",
		"eligible", eligible,
		"processed", processed,
		"enriched", enriched,
		"settled_none", settledNone,
		"left_null", leftNull,
		"errored", errored)
}

// topKByWeight returns the highest-weighted tags, tie-broken by name for
// determinism, capped at k (k<=0 means no cap).
func topKByWeight(merged map[string]float64, k int) []Tag {
	tags := make([]Tag, 0, len(merged))
	for name, w := range merged {
		tags = append(tags, Tag{Name: name, Weight: w})
	}
	sort.Slice(tags, func(i, j int) bool {
		if tags[i].Weight != tags[j].Weight {
			return tags[i].Weight > tags[j].Weight
		}
		return tags[i].Name < tags[j].Name
	})
	if k > 0 && len(tags) > k {
		tags = tags[:k]
	}
	return tags
}

// contributorIDs returns the sorted set of provider IDs that returned tags.
func contributorIDs(set map[string]bool) []string {
	ids := make([]string, 0, len(set))
	for id := range set {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	return ids
}

// sourceLabel maps the contributing providers onto the tag_source value:
// none / a single provider's own ID / "mixed" for two or more.
func sourceLabel(ids []string) string {
	switch len(ids) {
	case 0:
		return sourceNone
	case 1:
		return ids[0]
	default:
		return sourceMixed
	}
}

// uuidString renders a pgtype.UUID for logs.
func uuidString(u pgtype.UUID) string {
	if !u.Valid {
		return "<nil>"
	}
	return fmt.Sprintf("%x-%x-%x-%x-%x",
		u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}
