package library

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

	"github.com/dhowden/tag"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"

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

// pgUniqueViolation is the SQLSTATE for a unique-constraint conflict.
const pgUniqueViolation = "23505"

// isUniqueViolation reports whether err is a Postgres unique-constraint
// conflict. Used to distinguish "another row already owns this MBID"
// (a duplicate album/artist row in our DB, the operator should merge
// it) from genuine write failures.
func isUniqueViolation(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation
}

// BackfillMBIDsResult tallies what the one-shot worker did.
type BackfillMBIDsResult struct {
	Processed  int // Albums considered (mbid IS NULL).
	Healed     int // Albums whose mbid we set.
	Skipped    int // Albums where the file was missing or had no MBID tag.
	Duplicates int // Albums whose mbid is already held by another row (operator-resolvable).
}

// BackfillMBIDs walks albums with NULL mbid, opens one track per album,
// extracts MusicBrainz IDs from the tags, and persists them. Also heals
// the artist's mbid when the same tag-read yields one. When a row is
// healed, any 'none' cover_art_source on the album is cleared back to
// NULL so the enricher batch retries.
//
// Intended use: one-shot at server boot. Re-running it after the first
// pass costs the same N file reads but is a no-op for healed rows.
//
// limit caps how many albums are processed in one call. Pass -1 for no
// cap. The caller normally batches (e.g. limit=500 per boot) to avoid
// stalling startup on huge libraries.
func BackfillMBIDs(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
	limit int, progressCb func(BackfillMBIDsResult)) (BackfillMBIDsResult, error) {
	q := dbq.New(pool)
	queryLimit := int32(limit)
	if limit < 0 {
		queryLimit = 1<<31 - 1 // effectively unbounded
	}

	rows, err := q.ListAlbumsMissingMbidWithTrack(ctx, queryLimit)
	if err != nil {
		return BackfillMBIDsResult{}, fmt.Errorf("list missing mbid: %w", err)
	}

	var res BackfillMBIDsResult
	for i, r := range rows {
		if ctx.Err() != nil {
			return res, ctx.Err()
		}
		res.Processed++

		albumMBID, artistMBID := readMBIDsForFile(r.TrackFilePath, logger)
		if albumMBID == "" {
			res.Skipped++
			if progressCb != nil {
				progressCb(res)
			}
			continue
		}

		// Heal album.
		m := albumMBID
		if err := q.SetAlbumMbidIfNull(ctx, dbq.SetAlbumMbidIfNullParams{
			ID: r.AlbumID, Mbid: &m,
		}); err != nil {
			if isUniqueViolation(err) {
				// Another album row already owns this MBID — i.e. our DB
				// has two rows for the same MusicBrainz release (split by
				// title/artist disagreements during the pre-MBID scan).
				// Operator must merge them; for now, leave this row's
				// mbid NULL and skip the artist + cover-clear steps.
				logger.Info("mbid backfill: duplicate album mbid (canonical row already owns it)",
					"album_id", r.AlbumID, "mbid", albumMBID)
				res.Duplicates++
				if progressCb != nil {
					progressCb(res)
				}
				continue
			}
			logger.Warn("mbid backfill: set album mbid failed",
				"album_id", r.AlbumID, "err", err)
			res.Skipped++
			if progressCb != nil {
				progressCb(res)
			}
			continue
		}

		// Heal artist (if we got one).
		if artistMBID != "" {
			am := artistMBID
			if err := q.SetArtistMbidIfNull(ctx, dbq.SetArtistMbidIfNullParams{
				ID: r.ArtistID, Mbid: &am,
			}); err != nil {
				if isUniqueViolation(err) {
					logger.Info("mbid backfill: duplicate artist mbid (canonical row already owns it)",
						"artist_id", r.ArtistID, "mbid", artistMBID)
				} else {
					logger.Warn("mbid backfill: set artist mbid failed",
						"artist_id", r.ArtistID, "err", err)
				}
			}
		}

		// Clear cover_art_source = 'none' so the cover enricher's next
		// batch retries the MBCAA fetch for this album.
		if err := q.ClearAlbumCoverNone(ctx, r.AlbumID); err != nil {
			logger.Warn("mbid backfill: clear cover none failed",
				"album_id", r.AlbumID, "err", err)
		}

		res.Healed++

		// Progress log every 100 healed.
		if (i+1)%100 == 0 {
			logger.Info("mbid backfill progress",
				"processed", res.Processed, "healed", res.Healed,
				"skipped", res.Skipped, "duplicates", res.Duplicates)
		}
		if progressCb != nil {
			progressCb(res)
		}
	}

	logger.Info("mbid backfill complete",
		"processed", res.Processed,
		"healed", res.Healed,
		"skipped", res.Skipped,
		"duplicates", res.Duplicates)
	return res, nil
}

// readMBIDsForFile opens a single audio file, reads tags via dhowden/tag,
// and returns extracted album + artist MBIDs. Returns ("", "") when the
// file can't be opened or the tag read fails — those errors are logged
// at Warn level but not propagated, so a single broken file doesn't
// halt the whole backfill.
func readMBIDsForFile(path string, logger *slog.Logger) (albumMBID, artistMBID string) {
	f, err := os.Open(path)
	if err != nil {
		logger.Warn("mbid backfill: open failed", "path", path, "err", err)
		return "", ""
	}
	defer func() { _ = f.Close() }()

	meta, err := tag.ReadFrom(f)
	if err != nil {
		logger.Warn("mbid backfill: tag read failed", "path", path, "err", err)
		return "", ""
	}
	return extractMBIDs(meta)
}

// BackfillTrackMBIDsResult tallies the track recording-MBID backfill.
type BackfillTrackMBIDsResult struct {
	Processed int // Tracks considered (mbid IS NULL).
	Healed    int // Tracks whose recording MBID we set.
	Skipped   int // Tracks whose file was missing / untagged / write failed.
}

// BackfillTrackMBIDs walks tracks with NULL mbid, re-reads each file's
// tags, and persists the MusicBrainz recording MBID. This is the
// counterpart to BackfillMBIDs (which heals album/artist MBIDs); it
// unblocks the ListenBrainz similarity pipeline, which is gated on
// tracks.mbid IS NOT NULL.
//
// Idempotent via SetTrackMbidIfNull: re-running costs the same N file
// reads but is a no-op for already-healed rows. limit caps one call;
// pass -1 for unbounded (caller normally batches per scan).
func BackfillTrackMBIDs(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
	limit int, progressCb func(BackfillTrackMBIDsResult)) (BackfillTrackMBIDsResult, error) {
	q := dbq.New(pool)
	queryLimit := int32(limit)
	if limit < 0 {
		queryLimit = 1<<31 - 1 // effectively unbounded
	}

	rows, err := q.ListTracksMissingMbidWithPath(ctx, queryLimit)
	if err != nil {
		return BackfillTrackMBIDsResult{}, fmt.Errorf("list tracks missing mbid: %w", err)
	}

	var res BackfillTrackMBIDsResult
	for i, r := range rows {
		if ctx.Err() != nil {
			return res, ctx.Err()
		}
		res.Processed++

		rec := readRecordingMBIDForFile(r.FilePath, logger)
		if rec == "" {
			res.Skipped++
			if progressCb != nil {
				progressCb(res)
			}
			continue
		}

		m := rec
		if uerr := q.SetTrackMbidIfNull(ctx, dbq.SetTrackMbidIfNullParams{
			ID: r.ID, Mbid: &m,
		}); uerr != nil {
			logger.Warn("track mbid backfill: set failed",
				"track_id", r.ID, "err", uerr)
			res.Skipped++
			if progressCb != nil {
				progressCb(res)
			}
			continue
		}

		res.Healed++
		if (i+1)%500 == 0 {
			logger.Info("track mbid backfill progress",
				"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
		}
		if progressCb != nil {
			progressCb(res)
		}
	}

	logger.Info("track mbid backfill complete",
		"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
	return res, nil
}

// readRecordingMBIDForFile opens one audio file and returns its
// MusicBrainz recording MBID, or "" when the file can't be opened, the
// tag read fails, or the tag is absent. Errors are logged at Warn, not
// propagated — one broken file must not halt the backfill.
func readRecordingMBIDForFile(path string, logger *slog.Logger) string {
	f, err := os.Open(path)
	if err != nil {
		logger.Warn("track mbid backfill: open failed", "path", path, "err", err)
		return ""
	}
	defer func() { _ = f.Close() }()

	meta, err := tag.ReadFrom(f)
	if err != nil {
		logger.Warn("track mbid backfill: tag read failed", "path", path, "err", err)
		return ""
	}
	return extractRecordingMBID(meta)
}
