package explore

import (
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"sync"
	"time"

	"yellowjacket/backend/database"
	"yellowjacket/backend/system"
)

// ErrCoverArt is returned when the Cover Art Archive responds
// with a non-200 status code.
var ErrCoverArt = errors.New("cover art fetch failed")

const (
	// thumbnailDir is the subdirectory under the user data dir
	// where cached cover art thumbnails are stored.
	thumbnailDir = CoverArtCacheDirName

	// thumbnailTimeout is the HTTP timeout for fetching a thumbnail,
	// and it has to cover a redirect the Cover Art Archive does not
	// serve itself.
	//
	// `coverartarchive.org` answers `front-250` with a 307 to an
	// Internet Archive storage node (`dn######.us.archive.org`), and
	// those nodes are routinely slow: measured against the twelve
	// albums on Explore's own shelves, a successful fetch took 14–16 s
	// and a failing one 13–17 s. At 10 s *every* cover on the page
	// timed out — 24 cards, 5 of which had art, all of those from the
	// disk cache — which reads as "Explore has no album art" rather
	// than as a slow upstream, because a timeout writes nothing and
	// says nothing.
	//
	// 30 s is chosen to clear that measured range with room, not to be
	// generous: the fetch is off the critical path (each one is its own
	// goroutine behind an 8/s limiter, and the frontend renders a
	// placeholder until it lands), so the cost of waiting is nothing
	// and the cost of giving up early is a blank page.
	thumbnailTimeout = 30 * time.Second

	// thumbnailMaxSize is the maximum image size to cache (2 MB).
	thumbnailMaxSize = 2 * 1024 * 1024
)

// CoverArtProxy fetches and caches cover art thumbnails locally.
// It checks two sources in order:
//  1. Disk cache from a previous CAA fetch (instant)
//  2. Cover Art Archive network fetch (slow, cached to disk)
//
// The proxy used to also consult the local library's cover_art
// table by album/artist name, but that path conflated externally-
// fetched art with audio-file embedded ID3 art (the cover_art
// table writes both with is_embedded=true, so they're
// indistinguishable downstream).  For autotag review and explore
// browsing we want the canonical CAA cover, not whatever bytes
// happen to be tagged on a user's local file — so the library
// lookup was removed.  The user's own library views (cover grid,
// album page) still display embedded art via a separate code path
// that reads cover_art.file_path directly, which is fine because
// that's their library, not Explore's view of MB.
type CoverArtProxy struct {
	cacheDir string
	client   *http.Client
	limiter  *RateLimiter

	mu sync.Mutex // serializes disk writes
}

// NewCoverArtProxy creates a proxy that caches CAA thumbnails
// under the user data directory.  The db parameter is accepted
// for API stability but is no longer read; future cover-art
// logic that needs DB access can wire it back up.
func NewCoverArtProxy(_ *database.DB, limiter *RateLimiter) *CoverArtProxy {
	dir := ""

	dataDir, err := system.GetUserDataDirPath()
	if err == nil {
		dir = filepath.Join(dataDir, thumbnailDir)
		_ = os.MkdirAll(dir, 0o755)
	}

	return &CoverArtProxy{
		cacheDir: dir,
		client:   &http.Client{Timeout: thumbnailTimeout},
		limiter:  limiter,
	}
}

// GetThumbnail returns a base64-encoded JPEG data URL for the
// given release group.  Checks the disk cache first; falls back
// to a CAA network fetch.  Returns "" on failure.  The albumName
// / artistName args are accepted for API stability and ignored
// (they used to drive a library-by-name lookup; see the proxy
// type comment for why that was removed).
//
// The mbid argument MUST be a release group MBID.  Track-level cover
// art (where you only have a release MBID) should be resolved by
// looking up the parent release group via SearchIndex first.
func (p *CoverArtProxy) GetThumbnail(
	releaseGroupMBID, albumName, artistName string,
) string {
	if cached := p.GetThumbnailCached(releaseGroupMBID, albumName, artistName); cached != "" {
		return cached
	}

	if p.cacheDir == "" || releaseGroupMBID == "" {
		return ""
	}

	// A 404 is an answer, and it is already on disk.
	//
	// `writeCache(mbid, nil)` has recorded "the archive has no art for
	// this" as an empty file since this was written, and nothing has
	// ever read it back: `readCache` returns "" for an empty file,
	// which is indistinguishable from a miss, so every art-less release
	// group was re-fetched from the network on every render that asked
	// about it. On Explore's shelves a third of the cards are art-less,
	// so that was a third of the page spending a live CAA request to be
	// told again what the last one said.
	if p.knownMissing(releaseGroupMBID) {
		return ""
	}

	// Source 3: fetch from Cover Art Archive (slow, cached to disk).
	url := CoverArtGroupURL(releaseGroupMBID)
	data, cacheable, err := p.fetch(url)

	if err != nil || len(data) == 0 {
		if cacheable {
			p.writeCache(releaseGroupMBID, nil)
		}

		return ""
	}

	p.writeCache(releaseGroupMBID, data)

	return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}

// GetThumbnailCached returns the disk-cached cover art for the
// release group, or "" if it isn't on disk.  Does NOT fetch from
// the network.  albumName/artistName accepted for API stability
// and ignored — see the proxy type comment.
func (p *CoverArtProxy) GetThumbnailCached(
	releaseGroupMBID, _, _ string,
) string {
	if p.cacheDir == "" || releaseGroupMBID == "" {
		return ""
	}

	return p.readCache(releaseGroupMBID)
}

// GetTrackThumbnail returns cover art for a track.  Tries, in order:
//  1. Disk cache for the release group MBID (shared with discography).
//  2. Disk cache for the release MBID (per-track fallback).
//  3. CAA network fetch on the release group (populates RG cache).
//  4. CAA network fetch on the release (populates release cache).
//
// Either or both MBIDs may be empty — whichever is present is tried.
// Release group is preferred because it shares the cache with the
// discography and top-releases sections; release is the fallback for
// tracks whose caa_release_mbid doesn't resolve to a known RG in the
// index (e.g. the track is on a release not fetched for that artist).
//
// The albumName/artistName args are accepted for API stability and
// ignored — see the proxy type comment for why the library-by-name
// step was removed.
func (p *CoverArtProxy) GetTrackThumbnail(
	releaseMBID, releaseGroupMBID, _, _ string,
) string {
	return p.GetCandidateThumbnail(releaseMBID, releaseGroupMBID)
}

// GetCandidateThumbnail is the canonical CAA-only lookup: disk
// cache for the release group, disk cache for the release, then
// CAA network on each in turn.  Used everywhere the user is
// browsing or reviewing albums that aren't *their* library copy
// (autotag review, explore) — for those views we want the
// canonical CAA art, not whatever ID3 bytes happen to be tagged
// on a local file.  Returns "" when no art is available.
func (p *CoverArtProxy) GetCandidateThumbnail(
	releaseMBID, releaseGroupMBID string,
) string {
	if p.cacheDir == "" {
		return ""
	}

	// Disk cache for release group (shared with discography).
	if releaseGroupMBID != "" {
		if cached := p.readCache(releaseGroupMBID); cached != "" {
			return cached
		}
	}

	// Disk cache for release (per-track fallback).
	if releaseMBID != "" {
		if cached := p.readCache(releaseMBID); cached != "" {
			return cached
		}
	}

	// Network fetch on release group — unless a previous one was told
	// there is none. See `knownMissing`.
	if releaseGroupMBID != "" && !p.knownMissing(releaseGroupMBID) {
		url := CoverArtGroupURL(releaseGroupMBID)
		data, cacheable, err := p.fetch(url)

		if err == nil && len(data) > 0 {
			p.writeCache(releaseGroupMBID, data)

			return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
		}

		if cacheable {
			p.writeCache(releaseGroupMBID, nil)
		}
	}

	// Network fetch on release (fallback).
	if releaseMBID != "" && !p.knownMissing(releaseMBID) {
		url := CoverArtURL(releaseMBID)
		data, cacheable, err := p.fetch(url)

		if err != nil || len(data) == 0 {
			if cacheable {
				p.writeCache(releaseMBID, nil)
			}

			return ""
		}

		p.writeCache(releaseMBID, data)

		return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
	}

	return ""
}

// GetTrackThumbnailCached returns the disk-cached track thumbnail
// (RG MBID first, then release MBID).  Returns "" when nothing is
// cached.  Does NOT fetch from the network.  albumName/artistName
// accepted for API stability and ignored — see the proxy type
// comment.
func (p *CoverArtProxy) GetTrackThumbnailCached(
	releaseMBID, releaseGroupMBID, _, _ string,
) string {
	if p.cacheDir == "" {
		return ""
	}

	if releaseGroupMBID != "" {
		if cached := p.readCache(releaseGroupMBID); cached != "" {
			return cached
		}
	}

	if releaseMBID != "" {
		if cached := p.readCache(releaseMBID); cached != "" {
			return cached
		}
	}

	return ""
}

// ---------------------------------------------------------------------------
// CAA disk cache and network fetch
// ---------------------------------------------------------------------------

func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) {
	ctx := context.Background()
	if err := p.limiter.Wait(ctx); err != nil {
		return nil, false, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, false, err
	}

	req.Header.Set("User-Agent", lbUserAgent)

	resp, err := p.client.Do(req)
	if err != nil {
		return nil, false, err
	}

	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode == http.StatusNotFound {
		return nil, true, nil
	}

	if resp.StatusCode != http.StatusOK {
		return nil, false, fmt.Errorf("%w: %d", ErrCoverArt, resp.StatusCode)
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, thumbnailMaxSize))
	if err != nil {
		return nil, false, err
	}

	return data, true, nil
}

func (p *CoverArtProxy) cachePath(mbid string) string {
	return filepath.Join(p.cacheDir, mbid+".jpg")
}

// knownMissing reports whether a previous fetch was told the archive
// has no art for this MBID — the empty file `writeCache(mbid, nil)`
// leaves behind. It is deliberately separate from `readCache`, which
// answers "what are the bytes" and cannot express the difference
// between no answer and an answer of none.
func (p *CoverArtProxy) knownMissing(mbid string) bool {
	info, err := os.Stat(p.cachePath(mbid))

	return err == nil && info.Size() == 0
}

func (p *CoverArtProxy) readCache(mbid string) string {
	path := p.cachePath(mbid)

	data, err := os.ReadFile(path)
	if err != nil {
		return ""
	}

	if len(data) == 0 {
		return ""
	}

	return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}

func (p *CoverArtProxy) writeCache(mbid string, data []byte) {
	p.mu.Lock()
	defer p.mu.Unlock()

	path := p.cachePath(mbid)

	if data == nil {
		data = []byte{}
	}

	_ = os.WriteFile(path, data, 0o644)
}
