package explore

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"strings"
	"time"
	"unicode"

	"go.uploadedlobster.com/mbtypes"
	"go.uploadedlobster.com/musicbrainzws2"
)

// How long a MusicBrainz answer is kept.
//
// The rule is what the answer is *about*, not how big it is.  A search
// is a ranking and shifts; an entity is a fact about a record that was
// published years ago and does not.  Entity data used to expire after a
// week, which meant a fully-populated artist page re-fetched itself
// every week forever - on a real install, 251 of 2,930 cached rows were
// already expired and waiting to be paid for again.  The bytes are
// already on disk; re-fetching them buys nothing and spends someone
// else's rate limit.
const (
	// cacheTTLSearch is the TTL for search results (results may shift).
	cacheTTLSearch = 24 * time.Hour
	// cacheTTLEntity is the TTL for lookup/browse results.  An artist's
	// name, country and relations, a release group's title and date:
	// these change on the order of never, and a wrong one is corrected
	// by the next catalog artifact rather than by an expiry.
	cacheTTLEntity = 365 * 24 * time.Hour
	// cacheTTLReleases is the TTL for a release group's releases +
	// tracklists.  Effectively immutable once published.
	cacheTTLReleases = 365 * 24 * time.Hour
)

// MusicBrainzClient wraps the musicbrainzws2 library with a local
// response cache.  Every API call checks the cache first and stores
// successful responses for future hits.
//
// A proactive rate limiter gates all outgoing requests at 1 req/sec
// to avoid triggering MusicBrainz 429 responses.  The underlying
// musicbrainzws2.Client still retries on 429 as a safety net, but
// the limiter should prevent most rate-limit hits.
type MusicBrainzClient struct {
	mb      *musicbrainzws2.Client
	cache   *Cache
	limiter *RateLimiter
	logger  *slog.Logger
}

// NewMusicBrainzClient creates a MusicBrainz API client that caches
// responses in the given Cache.  The provided rate limiter is shared
// with all other MB consumers (e.g. artist image resolution) to
// prevent concurrent bursts from triggering 429s.
func NewMusicBrainzClient(
	cache *Cache,
	limiter *RateLimiter,
	logger *slog.Logger,
) *MusicBrainzClient {
	mb := musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
		Name:    "YellowJacket",
		Version: "dev",
		URL:     "https://github.com/yellowjacket",
	})

	return &MusicBrainzClient{
		mb:      mb,
		cache:   cache,
		limiter: limiter,
		logger:  logger,
	}
}

// Close releases resources held by the underlying HTTP client.
func (c *MusicBrainzClient) Close() error {
	return c.mb.Close()
}

// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------

// SearchArtists queries MusicBrainz for artists matching the given
// query string.  Returns results, the total match count from MB,
// and any error.  Results are cached for 1 day.
func (c *MusicBrainzClient) SearchArtists(
	ctx context.Context, query string, limit int,
) ([]MBArtist, int, error) {
	cacheKey := fmt.Sprintf("mb:search:artist:%s:%d", query, limit)

	if data, ok := c.cache.Get(cacheKey); ok {
		var cached mbSearchCache[MBArtist]
		if err := json.Unmarshal(data, &cached); err == nil {
			return cached.Results, cached.TotalCount, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, 0, err
	}

	c.logger.Info("musicbrainz search artists",
		"query", query,
		"limit", limit,
	)

	result, err := c.mb.SearchArtists(ctx,
		musicbrainzws2.SearchFilter{Query: query},
		musicbrainzws2.Paginator{Limit: clampLimit(limit)},
	)
	if err != nil {
		return nil, 0, err
	}

	out := convertArtists(result.Artists)

	c.cacheJSON(cacheKey, mbSearchCache[MBArtist]{
		Results: out, TotalCount: result.Count,
	}, cacheTTLSearch, "", "")

	return out, result.Count, nil
}

// SearchReleaseGroups queries MusicBrainz for release groups
// matching the given query string.
func (c *MusicBrainzClient) SearchReleaseGroups(
	ctx context.Context, query string, limit int,
) ([]MBReleaseGroup, int, error) {
	cacheKey := fmt.Sprintf("mb:search:release-group:%s:%d", query, limit)

	if data, ok := c.cache.Get(cacheKey); ok {
		var cached mbSearchCache[MBReleaseGroup]
		if err := json.Unmarshal(data, &cached); err == nil {
			return cached.Results, cached.TotalCount, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, 0, err
	}

	c.logger.Info("musicbrainz search release groups",
		"query", query,
		"limit", limit,
	)

	result, err := c.mb.SearchReleaseGroups(ctx,
		musicbrainzws2.SearchFilter{Query: query},
		musicbrainzws2.Paginator{Limit: clampLimit(limit)},
	)
	if err != nil {
		return nil, 0, err
	}

	out := convertReleaseGroups(result.ReleaseGroups)

	c.cacheJSON(cacheKey, mbSearchCache[MBReleaseGroup]{
		Results: out, TotalCount: result.Count,
	}, cacheTTLSearch, "", "")

	return out, result.Count, nil
}

// SearchRecordings queries MusicBrainz for recordings matching the
// given query string.
func (c *MusicBrainzClient) SearchRecordings(
	ctx context.Context, query string, limit int,
) ([]MBRecording, int, error) {
	cacheKey := fmt.Sprintf("mb:search:recording:%s:%d", query, limit)

	if data, ok := c.cache.Get(cacheKey); ok {
		var cached mbSearchCache[MBRecording]
		if err := json.Unmarshal(data, &cached); err == nil {
			return cached.Results, cached.TotalCount, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, 0, err
	}

	c.logger.Info("musicbrainz search recordings",
		"query", query,
		"limit", limit,
	)

	result, err := c.mb.SearchRecordings(ctx,
		musicbrainzws2.SearchFilter{Query: query},
		musicbrainzws2.Paginator{Limit: clampLimit(limit)},
	)
	if err != nil {
		return nil, 0, err
	}

	out := convertRecordings(result.Recordings)

	c.cacheJSON(cacheKey, mbSearchCache[MBRecording]{
		Results: out, TotalCount: result.Count,
	}, cacheTTLSearch, "", "")

	return out, result.Count, nil
}

// mbSearchCache wraps search results with the total count for caching.
type mbSearchCache[T any] struct {
	Results    []T `json:"results"`
	TotalCount int `json:"totalCount"`
}

// ---------------------------------------------------------------------------
// Lookup
// ---------------------------------------------------------------------------

// LookupArtist fetches a single artist by MBID.  Cached for 7 days.
// Uses inc=release-groups to pre-populate the browse cache so the
// subsequent BrowseReleaseGroups call is a free cache hit.
func (c *MusicBrainzClient) LookupArtist(
	ctx context.Context, mbid string,
) (*MBArtist, error) {
	cacheKey := "mb:lookup:artist:" + mbid

	if data, ok := c.cache.Get(cacheKey); ok {
		var out MBArtist
		if err := json.Unmarshal(data, &out); err == nil {
			return &out, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}

	c.logger.Info("musicbrainz lookup artist", "mbid", mbid)

	a, err := c.mb.LookupArtist(ctx,
		mbtypes.MBID(mbid),
		musicbrainzws2.IncludesFilter{Includes: []string{"release-groups"}},
	)
	if err != nil {
		return nil, err
	}

	out := convertArtist(a)

	c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "artist")

	// Pre-populate the browse cache with the included release groups
	// so BrowseReleaseGroups returns instantly from cache.
	// The inc= response is limited to 25 items; only cache if we
	// likely got the full discography (< 25 means no truncation).
	if len(a.ReleaseGroups) > 0 && len(a.ReleaseGroups) < 25 {
		browseKey := "mb:browse:release-groups:" + mbid
		rgs := convertReleaseGroups(a.ReleaseGroups)
		c.cacheJSON(browseKey, rgs, cacheTTLEntity, mbid, "artist")
	}

	return &out, nil
}

// LookupReleaseGroup fetches a single release group by MBID.
func (c *MusicBrainzClient) LookupReleaseGroup(
	ctx context.Context, mbid string,
) (*MBReleaseGroup, error) {
	cacheKey := "mb:lookup:release-group:" + mbid

	if data, ok := c.cache.Get(cacheKey); ok {
		var out MBReleaseGroup
		if err := json.Unmarshal(data, &out); err == nil {
			return &out, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}

	c.logger.Info("musicbrainz lookup release group", "mbid", mbid)

	rg, err := c.mb.LookupReleaseGroup(ctx,
		mbtypes.MBID(mbid),
		musicbrainzws2.IncludesFilter{Includes: []string{"artist-credits"}},
	)
	if err != nil {
		return nil, err
	}

	out := convertReleaseGroup(rg)

	c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release-group")

	return &out, nil
}

// ---------------------------------------------------------------------------
// Browse
// ---------------------------------------------------------------------------

// BrowseReleaseGroups fetches the release groups for a given artist
// MBID.  Cached for 7 days.
func (c *MusicBrainzClient) BrowseReleaseGroups(
	ctx context.Context, artistMBID string,
) ([]MBReleaseGroup, error) {
	cacheKey := "mb:browse:release-groups:" + artistMBID

	if data, ok := c.cache.Get(cacheKey); ok {
		var out []MBReleaseGroup
		if err := json.Unmarshal(data, &out); err == nil {
			return out, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}

	c.logger.Info("musicbrainz browse release groups",
		"artistMBID", artistMBID,
	)

	result, err := c.mb.BrowseReleaseGroups(ctx,
		musicbrainzws2.ReleaseGroupFilter{
			ArtistMBID: mbtypes.MBID(artistMBID),
		},
		musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit},
	)
	if err != nil {
		return nil, err
	}

	out := convertReleaseGroups(result.ReleaseGroups)

	c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist")

	return out, nil
}

// browseMaxPages bounds BrowseReleaseGroupsAll.  At MaxLimit per page
// that is 1 000 release groups, which no real artist reaches — it is a
// runaway guard for a server that stops honouring the offset, not a
// coverage decision.
const browseMaxPages = 10

// BrowseReleaseGroupsAll is BrowseReleaseGroups paged to exhaustion.
//
// The single-page call above asks for MaxLimit (100) and takes whatever
// comes back, which silently truncates a prolific artist's discography
// at 100 release groups — invisible unless you count, since a hundred
// albums looks like a complete page. This is the call to use when the
// answer is meant to be the whole discography rather than a page of it.
//
// The result is cached under the same key the single-page call reads,
// so a later interactive browse is served the complete list.
func (c *MusicBrainzClient) BrowseReleaseGroupsAll(
	ctx context.Context, artistMBID string,
) ([]MBReleaseGroup, error) {
	cacheKey := "mb:browse:release-groups:" + artistMBID

	if data, ok := c.cache.Get(cacheKey); ok {
		var out []MBReleaseGroup
		if err := json.Unmarshal(data, &out); err == nil {
			return out, nil
		}
	}

	var out []MBReleaseGroup

	for page := range browseMaxPages {
		if err := c.limiter.Wait(ctx); err != nil {
			return nil, err
		}

		offset := page * musicbrainzws2.MaxLimit

		c.logger.Info("musicbrainz browse release groups",
			"artistMBID", artistMBID,
			"offset", offset,
		)

		result, err := c.mb.BrowseReleaseGroups(ctx,
			musicbrainzws2.ReleaseGroupFilter{
				ArtistMBID: mbtypes.MBID(artistMBID),
			},
			musicbrainzws2.Paginator{
				Limit:  musicbrainzws2.MaxLimit,
				Offset: offset,
			},
		)
		if err != nil {
			// Pages already fetched are still worth keeping if there are
			// any: a partial discography beats none, and the caller's
			// mark is only set on a nil error, so the rest is retried.
			if len(out) > 0 {
				return out, nil
			}

			return nil, err
		}

		out = append(out, convertReleaseGroups(result.ReleaseGroups)...)

		// A short page is the last page.  MB reports the full count too,
		// but a short page is the condition that terminates correctly
		// even when the count and the pages disagree.
		if len(result.ReleaseGroups) < musicbrainzws2.MaxLimit {
			break
		}
	}

	c.cacheJSON(cacheKey, out, cacheTTLEntity, artistMBID, "artist")

	return out, nil
}

// LookupRelease fetches a single release by MBID (with media +
// recordings).  Used by the autotag paste-URL escape hatch.
// Cached for 7 days.
func (c *MusicBrainzClient) LookupRelease(
	ctx context.Context, mbid string,
) (*MBRelease, error) {
	cacheKey := "mb:lookup:release:" + mbid

	if data, ok := c.cache.Get(cacheKey); ok {
		var out MBRelease
		if err := json.Unmarshal(data, &out); err == nil {
			return &out, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}

	c.logger.Info("musicbrainz lookup release", "mbid", mbid)

	r, err := c.mb.LookupRelease(
		ctx,
		mbtypes.MBID(mbid),
		musicbrainzws2.IncludesFilter{
			Includes: []string{"recordings", "media", "artist-credits", "release-groups"},
		},
	)
	if err != nil {
		return nil, err
	}

	out := convertRelease(r)

	c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release")

	return &out, nil
}

// MBRecordingRelease is a slim reference to one release a recording
// appears on — enough for the autotagger to pick a representative
// release and then LookupRelease it in full.
type MBRecordingRelease struct {
	MBID   string `json:"mbid"`
	Title  string `json:"title"`
	Status string `json:"status"`
	Date   string `json:"date"`
}

// LookupRecordingReleases fetches the releases a recording appears on
// (id / title / status / date only).  Used by the autotag recording-
// search path to resolve a picked recording to a concrete release.
// Cached for 7 days.
func (c *MusicBrainzClient) LookupRecordingReleases(
	ctx context.Context, recordingMBID string,
) ([]MBRecordingRelease, error) {
	cacheKey := "mb:lookup:recording-releases:" + recordingMBID

	if data, ok := c.cache.Get(cacheKey); ok {
		var out []MBRecordingRelease
		if err := json.Unmarshal(data, &out); err == nil {
			return out, nil
		}
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}

	c.logger.Info("musicbrainz lookup recording releases", "mbid", recordingMBID)

	rec, err := c.mb.LookupRecording(
		ctx,
		mbtypes.MBID(recordingMBID),
		musicbrainzws2.IncludesFilter{Includes: []string{"releases"}},
	)
	if err != nil {
		return nil, err
	}

	out := make([]MBRecordingRelease, 0, len(rec.Releases))
	for _, rel := range rec.Releases {
		out = append(out, MBRecordingRelease{
			MBID:   string(rel.ID),
			Title:  rel.Title,
			Status: rel.Status,
			Date:   rel.Date.String(),
		})
	}

	c.cacheJSON(cacheKey, out, cacheTTLEntity, recordingMBID, "recording")

	return out, nil
}

// BrowseReleases fetches the releases for a given release group
// MBID, including media/track information.  Cached for 7 days.
// browseReleasesCacheKey returns the response-cache key for a release
// group's releases.
func browseReleasesCacheKey(releaseGroupMBID string) string {
	return "mb:browse:releases:" + releaseGroupMBID
}

// BrowseReleasesCached returns a release group's releases from the local
// response cache only, never hitting the network.  The bool reports
// whether a fresh (unexpired) cache entry was found.  Used by the album
// page's local-first path so a cold fetch can be deferred to the
// background instead of blocking the request.
func (c *MusicBrainzClient) BrowseReleasesCached(
	releaseGroupMBID string,
) ([]MBRelease, bool) {
	data, ok := c.cache.Get(browseReleasesCacheKey(releaseGroupMBID))
	if !ok {
		return nil, false
	}

	var out []MBRelease
	if err := json.Unmarshal(data, &out); err != nil {
		return nil, false
	}

	return out, true
}

// BrowseReleases fetches all releases (with recordings + media) for a
// release group, serving from the local response cache when warm and
// otherwise hitting MusicBrainz and caching the result.
func (c *MusicBrainzClient) BrowseReleases(
	ctx context.Context, releaseGroupMBID string,
) ([]MBRelease, error) {
	cacheKey := browseReleasesCacheKey(releaseGroupMBID)

	if out, ok := c.BrowseReleasesCached(releaseGroupMBID); ok {
		return out, nil
	}

	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}

	c.logger.Info("musicbrainz browse releases",
		"releaseGroupMBID", releaseGroupMBID,
	)

	result, err := c.mb.BrowseReleases(ctx,
		musicbrainzws2.ReleaseFilter{
			ReleaseGroupMBID: mbtypes.MBID(releaseGroupMBID),
			Includes:         []string{"recordings", "media"},
		},
		musicbrainzws2.Paginator{Limit: musicbrainzws2.MaxLimit},
	)
	if err != nil {
		return nil, err
	}

	out := convertReleases(result.Releases)

	c.cacheJSON(cacheKey, out, cacheTTLReleases, releaseGroupMBID, "release-group")

	return out, nil
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// cacheJSON marshals v to JSON and stores it in the cache.
func (c *MusicBrainzClient) cacheJSON(
	key string,
	v any,
	ttl time.Duration,
	mbid string,
	entityType string,
) {
	data, err := json.Marshal(v)
	if err != nil {
		c.logger.Warn("musicbrainz cache marshal error",
			"key", key,
			"err", err,
		)

		return
	}

	c.cache.Set(key, data, ttl, mbid, entityType)
}

// clampLimit restricts the search limit to the MusicBrainz maximum.
func clampLimit(limit int) int {
	if limit <= 0 || limit > musicbrainzws2.MaxLimit {
		return musicbrainzws2.DefaultLimit
	}

	return limit
}

// ---------------------------------------------------------------------------
// Type converters (musicbrainzws2 → Wails wrapper types)
// ---------------------------------------------------------------------------

func convertArtist(a musicbrainzws2.Artist) MBArtist {
	out := MBArtist{
		MBID:           string(a.ID),
		Name:           a.Name,
		SortName:       a.SortName,
		Type:           a.Type,
		Country:        string(a.CountryCode),
		Disambiguation: a.Disambiguation,
		Score:          a.Score,
		OriginalScore:  a.Score,
	}

	// Extract the primary English alias when the canonical name
	// is non-Latin (CJK, Cyrillic, etc.).  This lets the frontend
	// show "Tatsuro Yamashita" alongside "山下達郎".
	if !isLatinScript(a.Name) {
		out.EnglishName = primaryEnglishAlias(a.Aliases)
	}

	return out
}

func convertArtists(artists []musicbrainzws2.Artist) []MBArtist {
	out := make([]MBArtist, len(artists))
	for i, a := range artists {
		out[i] = convertArtist(a)
	}

	return out
}

// primaryEnglishAlias returns the primary English alias name from
// a slice of aliases, or "" if none exists.
func primaryEnglishAlias(aliases []musicbrainzws2.Alias) string {
	// Prefer primary English alias.
	for _, a := range aliases {
		if a.Locale == "en" && a.IsPrimary {
			return a.Name
		}
	}

	// Fall back to any English alias.
	for _, a := range aliases {
		if a.Locale == "en" {
			return a.Name
		}
	}

	return ""
}

// isLatinScript returns true if the string consists primarily of
// Latin characters, digits, and common punctuation.  Returns false
// for CJK, Cyrillic, Arabic, etc.
func isLatinScript(s string) bool {
	for _, r := range s {
		if unicode.IsLetter(r) && !unicode.In(r, unicode.Latin) {
			return false
		}
	}

	return true
}

func convertReleaseGroup(rg musicbrainzws2.ReleaseGroup) MBReleaseGroup {
	return MBReleaseGroup{
		MBID:             string(rg.ID),
		Title:            rg.Title,
		PrimaryType:      rg.PrimaryType,
		SecondaryTypes:   rg.SecondaryTypes,
		FirstReleaseDate: rg.FirstReleaseDate.String(),
		ArtistCredit:     rg.ArtistCredit.String(),
		Score:            rg.Score,
	}
}

func convertReleaseGroups(rgs []musicbrainzws2.ReleaseGroup) []MBReleaseGroup {
	out := make([]MBReleaseGroup, len(rgs))
	for i, rg := range rgs {
		out[i] = convertReleaseGroup(rg)
	}

	return out
}

func convertRelease(r musicbrainzws2.Release) MBRelease {
	rel := MBRelease{
		MBID:         string(r.ID),
		Title:        r.Title,
		Date:         r.Date.String(),
		Country:      string(r.CountryCode),
		Status:       r.Status,
		ArtistCredit: r.ArtistCredit.String(),
	}

	if r.ReleaseGroup != nil {
		rel.ReleaseGroupMBID = string(r.ReleaseGroup.ID)
	}

	for _, m := range r.Media {
		// Skip video media outright — DVD/Blu-ray bonus discs
		// inflate track counts and wreck track-count-based scoring
		// (beets ignores video/data tracks for the same reason).
		if isVideoFormat(m.Format) {
			continue
		}

		for _, t := range m.Tracks {
			// Same for individual video recordings on audio media.
			if t.Recording.IsVideo {
				continue
			}

			// Use the recording MBID, not the track MBID.  Tracks
			// and recordings have distinct MBIDs in MusicBrainz:
			// a track is the placement of a recording on a specific
			// medium/release, while a recording is the underlying
			// audio work.  Library-tagged audio files store the
			// recording MBID (MusicBrainz Track Id is a misnomer),
			// so that's what the local recordings.mbid column
			// contains — and that's what we need to match against
			// for the library-status indicator to be accurate.
			recordingMBID := string(t.Recording.ID)
			if recordingMBID == "" {
				// Fall back to the track MBID if the API response
				// didn't include the recording relation (older
				// browse endpoints).  Better than empty.
				recordingMBID = string(t.ID)
			}

			rel.Tracks = append(rel.Tracks, MBTrack{
				Position:   t.Position,
				DiscNumber: m.Position,
				Title:      t.Title,
				Length:     int(t.Length.Milliseconds()),
				MBID:       recordingMBID,
			})
		}
	}

	return rel
}

// isVideoFormat reports whether a medium's format string names a
// video carrier.  "DVD-Audio" stays audio; bare "DVD", "DVD-Video",
// "Blu-ray", "HD-DVD", "VHS", "VCD"/"SVCD" are video.
func isVideoFormat(format string) bool {
	f := strings.ToLower(format)

	if strings.Contains(f, "dvd-audio") {
		return false
	}

	for _, v := range []string{"dvd", "blu-ray", "bluray", "hd-dvd", "vhs", "vcd"} {
		if strings.Contains(f, v) {
			return true
		}
	}

	return false
}

func convertReleases(releases []musicbrainzws2.Release) []MBRelease {
	out := make([]MBRelease, len(releases))
	for i, r := range releases {
		out[i] = convertRelease(r)
	}

	return out
}

func convertRecording(r musicbrainzws2.Recording) MBRecording {
	return MBRecording{
		MBID:         string(r.ID),
		Title:        r.Title,
		Length:       int(r.Length.Milliseconds()),
		ArtistCredit: r.ArtistCredit.String(),
		Score:        r.Score,
	}
}

func convertRecordings(recordings []musicbrainzws2.Recording) []MBRecording {
	out := make([]MBRecording, len(recordings))
	for i, r := range recordings {
		out[i] = convertRecording(r)
	}

	return out
}
