package coverart

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"

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

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

// EnrichArtist runs the chain for a single artist. Parallel to
// EnrichAlbum, but no sidecar layer (artists have no per-artist
// directory in a music library — images live in a managed cache
// under <dataDir>/artist-art/<artist_id>/).
//
// Eligibility:
//   - artist_art_source IS NULL                                → eligible
//   - artist_art_source IN (theaudiodb)                        → SKIP (found)
//   - artist_art_source = 'none' AND version = current         → SKIP
//   - artist_art_source = 'none' AND version != current        → flip to NULL, then enrich
//
// Chain: enabled ArtistArtProviders in registration order. First
// success returns. All-ErrNotFound settles to 'none'. Any
// ErrTransient leaves the row NULL for next-pass retry.
func (e *Enricher) EnrichArtist(ctx context.Context, artistID pgtype.UUID, dataDir string) error {
	q := dbq.New(e.pool)
	row, err := q.GetArtistByID(ctx, artistID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil
		}
		return fmt.Errorf("get artist: %w", err)
	}

	currentVersion := e.settings.CurrentVersion()

	// Eligibility check.
	currentSource := ""
	if row.ArtistArtSource != nil {
		currentSource = *row.ArtistArtSource
	}
	switch currentSource {
	case "theaudiodb":
		return nil
	case "none":
		if row.ArtistArtSourcesVersion == currentVersion {
			return nil
		}
		if err := q.ClearArtistArtNone(ctx, artistID); err != nil {
			return fmt.Errorf("clear stale artist none: %w", err)
		}
	}

	// Build the ref. Every provider receives it and decides whether it
	// can act on the fields available (MBID-only providers return
	// ErrNotFound when MBID is empty; name-based providers fall back to
	// Name).
	ref := ArtistRef{Name: row.Name}
	if row.Mbid != nil {
		ref.MBID = *row.Mbid
	}

	// Provider chain. allWere404 tracks whether every provider returned
	// ErrNotFound — only then do we settle to 'none'. Any transient
	// failure leaves the row NULL for next-pass retry.
	allWere404 := true
	for _, provider := range e.settings.EnabledArtistProviders() {
		thumb, fanart, perr := provider.FetchArtistArt(ctx, ref)
		if perr == nil {
			// Atomic write — at least one of (thumb, fanart) is non-nil
			// per the ArtistArtProvider contract; persist whichever
			// landed.
			artistDir := filepath.Join(dataDir, "artist-art", uuidString(artistID))
			if mkErr := os.MkdirAll(artistDir, 0o755); mkErr != nil {
				return fmt.Errorf("mkdir artist-art: %w", mkErr)
			}
			var thumbPath, fanartPath string
			if thumb != nil {
				thumbPath = filepath.Join(artistDir, "thumb.jpg")
				if werr := os.WriteFile(thumbPath, thumb, 0o644); werr != nil {
					e.logger.Error("coverart: write artist thumb failed",
						"artist_id", uuidString(artistID), "err", werr)
					thumbPath = ""
				}
			}
			if fanart != nil {
				fanartPath = filepath.Join(artistDir, "fanart.jpg")
				if werr := os.WriteFile(fanartPath, fanart, 0o644); werr != nil {
					e.logger.Error("coverart: write artist fanart failed",
						"artist_id", uuidString(artistID), "err", werr)
					fanartPath = ""
				}
			}
			return e.recordArtistArt(ctx, artistID, thumbPath, fanartPath, provider.ID(), currentVersion)
		}
		if !errors.Is(perr, ErrNotFound) {
			allWere404 = false
			e.logger.Warn("coverart: artist provider fetch failed; trying next",
				"artist_id", uuidString(artistID),
				"provider", provider.ID(), "err", perr)
		}
		// ErrNotFound: continue to next provider; allWere404 stays true.
	}

	if allWere404 {
		return e.recordArtistArt(ctx, artistID, "", "", "none", currentVersion)
	}
	return nil // at least one transient failure; leave NULL for retry
}

// recordArtistArt writes the new artist_thumb_path /
// artist_fanart_path / artist_art_source / artist_art_sources_version
// atomically.
func (e *Enricher) recordArtistArt(ctx context.Context, artistID pgtype.UUID, thumbPath, fanartPath, source string, version int32) error {
	q := dbq.New(e.pool)
	src := source
	var thumbArg, fanartArg *string
	if thumbPath != "" {
		thumbArg = &thumbPath
	}
	if fanartPath != "" {
		fanartArg = &fanartPath
	}
	return q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{
		ID:                      artistID,
		ArtistThumbPath:         thumbArg,
		ArtistFanartPath:        fanartArg,
		ArtistArtSource:         &src,
		ArtistArtSourcesVersion: version,
	})
}

// EnrichArtistBatch drains up to limit artists whose eligibility loop
// determines they need processing. Iterates serially. Returns
// per-batch tallies for the orchestrator's scan-run record.
//
// Emits a single Info log line at end-of-batch with a category
// breakdown (succeeded / settled-none / no-MBID / left-NULL /
// errored). The JSON tally written to scan_runs keeps the existing
// processed/succeeded/failed shape for backwards compat with the
// admin overview UI; the log line is the operator's diagnostic.
// limit: 0 = stage disabled; >0 = bounded; <0 = unbounded (#388 —
// no global cap; remote providers self-throttle per provider).
func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string,
	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 // unbounded
	}
	q := dbq.New(e.pool)
	rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{
		ArtistArtSourcesVersion: e.settings.CurrentVersion(),
		Limit:                   queryLimit,
	})
	if qerr != nil {
		return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr)
	}
	var settledNone, noMBID, leftNull, errored int
	for _, id := range rows {
		if ctx.Err() != nil {
			e.logArtistBatchSummary(len(rows), processed, succeeded, settledNone, noMBID, leftNull, errored)
			return processed, succeeded, failed, ctx.Err()
		}
		processed++
		if eerr := e.EnrichArtist(ctx, id, dataDir); eerr != nil {
			e.logger.Warn("coverart: artist batch entry failed",
				"artist_id", uuidString(id), "err", eerr)
			failed++
			errored++
			if progressCb != nil {
				progressCb(processed, succeeded, failed)
			}
			continue
		}
		// Re-read to classify.
		row, rerr := q.GetArtistByID(ctx, id)
		if rerr != nil {
			failed++
			errored++
			if progressCb != nil {
				progressCb(processed, succeeded, failed)
			}
			continue
		}
		switch {
		case row.ArtistArtSource != nil &&
			*row.ArtistArtSource != "" &&
			*row.ArtistArtSource != "none":
			succeeded++
		case row.ArtistArtSource != nil && *row.ArtistArtSource == "none":
			settledNone++
			failed++
		case row.Mbid == nil || *row.Mbid == "":
			noMBID++
			failed++
		default:
			leftNull++
			failed++
		}
		if progressCb != nil {
			progressCb(processed, succeeded, failed)
		}
	}
	e.logArtistBatchSummary(len(rows), processed, succeeded, settledNone, noMBID, leftNull, errored)
	return processed, succeeded, failed, nil
}

// logArtistBatchSummary emits a single Info line at end-of-batch with
// the full category breakdown including MBID-skipped (artists eligible
// by the SQL filter but skipped inside EnrichArtist due to NULL MBID).
func (e *Enricher) logArtistBatchSummary(eligible, processed, succeeded, settledNone, noMBID, leftNull, errored int) {
	e.logger.Info("coverart: artist enrichment batch complete",
		"eligible", eligible,
		"processed", processed,
		"succeeded", succeeded,
		"settled_none", settledNone,
		"no_mbid", noMBID,
		"left_null", leftNull,
		"errored", errored)
}

// CleanupArtistArt removes the artist-art directory for a deleted
// artist. Idempotent — no-op if the directory doesn't exist. Called
// from the artist-delete cascade in tracks/service.go after the
// transaction commits.
func CleanupArtistArt(dataDir string, artistID pgtype.UUID) error {
	dir := filepath.Join(dataDir, "artist-art", uuidString(artistID))
	if err := os.RemoveAll(dir); err != nil {
		return fmt.Errorf("remove artist-art dir: %w", err)
	}
	return nil
}
