package playlists

import (
	"context"
	"fmt"
	"image"
	"image/color"
	"image/draw"
	"image/jpeg"
	"os"
	"path/filepath"
	"strings"

	_ "image/png" // for decoding existing PNG covers (jpeg already linked above)

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

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

const (
	collageCellSize  = 300 // px per cell, 2x2 → 600x600 output
	collageOutputDim = collageCellSize * 2
	collageQuality   = 85
)

// GenerateCollage composes a 600x600 JPEG from the first 4 contributing
// tracks' album covers. Missing covers (no upstream album, empty
// cover_art_path, file unreadable) get the album-fallback glyph in their
// cell. Writes to <dataDir>/playlist_covers/<playlist_id>.jpg and
// updates playlists.cover_path. Returns the relative path written, or
// "" when the playlist is empty (cover_path also cleared).
func GenerateCollage(ctx context.Context, pool *pgxpool.Pool, playlistID pgtype.UUID, dataDir string) (string, error) {
	q := dbq.New(pool)
	// Pull more rows than cells so we can dedupe identical covers and
	// still fill every cell. A 25-track "Songs like X" playlist that
	// has 5 tracks each from 5 albums shouldn't render as four
	// identical cells of the first album's cover; pulling 50 and
	// deduping by cover_art_path gives us four distinct covers in
	// realistic libraries.
	allRows, err := q.ListAllPlaylistTracksForCollage(ctx, dbq.ListAllPlaylistTracksForCollageParams{
		PlaylistID: playlistID,
		Limit:      50,
	})
	if err != nil {
		return "", fmt.Errorf("list collage tracks: %w", err)
	}
	rows := dedupeCollageRows(allRows, 4)

	if len(rows) == 0 {
		// Empty playlist: clear cover_path and remove any stale file.
		if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{
			ID:        playlistID,
			CoverPath: nil,
		}); err != nil {
			return "", fmt.Errorf("clear cover_path: %w", err)
		}
		_ = os.Remove(filepath.Join(dataDir, "playlist_covers", uuidToFilename(playlistID)+".jpg"))
		return "", nil
	}

	out := image.NewRGBA(image.Rect(0, 0, collageOutputDim, collageOutputDim))
	// Fill with FabledSword `iron` (#1E2228) so any drawing gaps look intentional.
	draw.Draw(out, out.Bounds(), &image.Uniform{C: color.RGBA{0x1E, 0x22, 0x28, 0xFF}}, image.Point{}, draw.Src)

	for cell := 0; cell < 4; cell++ {
		var coverPath string
		if cell < len(rows) && rows[cell].AlbumCoverPath != nil && *rows[cell].AlbumCoverPath != "" {
			coverPath = *rows[cell].AlbumCoverPath
		}
		img := loadCellImage(coverPath, dataDir)
		col := cell % 2
		row := cell / 2
		dest := image.Rect(col*collageCellSize, row*collageCellSize, (col+1)*collageCellSize, (row+1)*collageCellSize)
		drawScaled(out, dest, img)
	}

	if err := os.MkdirAll(filepath.Join(dataDir, "playlist_covers"), 0o755); err != nil {
		return "", fmt.Errorf("mkdir playlist_covers: %w", err)
	}
	relPath := filepath.Join("playlist_covers", uuidToFilename(playlistID)+".jpg")
	full := filepath.Join(dataDir, relPath)
	tmp := full + ".tmp"
	f, err := os.Create(tmp)
	if err != nil {
		return "", fmt.Errorf("create collage file: %w", err)
	}
	if err := jpeg.Encode(f, out, &jpeg.Options{Quality: collageQuality}); err != nil {
		_ = f.Close()
		_ = os.Remove(tmp)
		return "", fmt.Errorf("encode jpeg: %w", err)
	}
	if err := f.Close(); err != nil {
		return "", fmt.Errorf("close collage file: %w", err)
	}
	if err := os.Rename(tmp, full); err != nil {
		return "", fmt.Errorf("rename: %w", err)
	}

	relPathPtr := relPath
	if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{
		ID:        playlistID,
		CoverPath: &relPathPtr,
	}); err != nil {
		return "", fmt.Errorf("set cover_path: %w", err)
	}
	return relPath, nil
}

// dedupeCollageRows trims the input to at most max rows such that no
// non-empty cover_art_path appears twice. Rows with NULL or empty
// cover_art_path are kept as-is — they each render the fallback glyph
// in their own cell, which is the right behavior (a partially-covered
// playlist still gets a sensible visual rather than collapsing every
// missing cover into a single cell).
//
// Preserves input order (which is playlist position order from the SQL).
func dedupeCollageRows(rows []dbq.ListAllPlaylistTracksForCollageRow, max int) []dbq.ListAllPlaylistTracksForCollageRow {
	seen := make(map[string]bool, max)
	out := make([]dbq.ListAllPlaylistTracksForCollageRow, 0, max)
	for _, r := range rows {
		if len(out) >= max {
			break
		}
		if r.AlbumCoverPath == nil || *r.AlbumCoverPath == "" {
			out = append(out, r)
			continue
		}
		if seen[*r.AlbumCoverPath] {
			continue
		}
		seen[*r.AlbumCoverPath] = true
		out = append(out, r)
	}
	return out
}

// loadCellImage tries to load the album cover at the given path. On any
// failure (empty path, missing file, decode error), returns the rendered
// fallback glyph as a 300x300 image.
func loadCellImage(coverPath, dataDir string) image.Image {
	if coverPath == "" {
		return fallbackGlyph()
	}
	full := coverPath
	if !filepath.IsAbs(coverPath) {
		full = filepath.Join(dataDir, coverPath)
	}
	f, err := os.Open(full)
	if err != nil {
		return fallbackGlyph()
	}
	defer func() { _ = f.Close() }()
	img, _, err := image.Decode(f)
	if err != nil {
		return fallbackGlyph()
	}
	return img
}

// fallbackGlyph returns a 300x300 image filled with the FabledSword
// "slate" surface tint and a centered solid box approximating the
// album-fallback glyph. Slice 1 trade-off: rasterizing the actual SVG
// at runtime requires a third-party SVG renderer (oksvg / etc.) and
// adds dependency + complexity. The solid placeholder reads as
// "this cell intentionally empty" without committing to SVG plumbing.
// A follow-up task can swap in real SVG rasterization.
func fallbackGlyph() image.Image {
	img := image.NewRGBA(image.Rect(0, 0, collageCellSize, collageCellSize))
	bg := color.RGBA{0x2C, 0x31, 0x3A, 0xFF} // FabledSword slate
	fg := color.RGBA{0x9C, 0x9A, 0x92, 0xFF} // FabledSword ash
	draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
	center := image.Rect(100, 100, 200, 200)
	draw.Draw(img, center, &image.Uniform{C: fg}, image.Point{}, draw.Src)
	return img
}

// drawScaled copies src into dst.Rect using a center-cropped "cover" fit
// (the same model as BoxFit.cover / object-fit: cover in the clients).
// Non-square sources are scaled so the *smaller* destination dimension is
// fully filled and the larger axis is center-cropped, preserving aspect
// ratio. Without this, banner-shaped or LP-shaped album art stretches in
// the cell -- the album-coherent system playlists (new_for_you,
// first_listens) make the stretching disproportionately visible because
// fewer unique covers contribute, so each warped cell is a quarter of
// the collage rather than diluted.
//
// Scaling itself stays nearest-neighbor -- stdlib lacks high-quality
// scaling and dependency cost is unjustified for this 600x600 output.
func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) {
	srcBounds := src.Bounds()
	srcW := srcBounds.Dx()
	srcH := srcBounds.Dy()
	if srcW <= 0 || srcH <= 0 {
		return
	}
	dstW := r.Dx()
	dstH := r.Dy()
	// Cover-fit: the side of src that maps to dst at the larger scale
	// fully fills its axis; the other axis is center-cropped.
	scaleX := float64(dstW) / float64(srcW)
	scaleY := float64(dstH) / float64(srcH)
	scale := scaleX
	if scaleY > scale {
		scale = scaleY
	}
	cropW := float64(dstW) / scale
	cropH := float64(dstH) / scale
	cropOffX := float64(srcBounds.Min.X) + (float64(srcW)-cropW)/2
	cropOffY := float64(srcBounds.Min.Y) + (float64(srcH)-cropH)/2
	for y := r.Min.Y; y < r.Max.Y; y++ {
		sy := int(cropOffY + float64(y-r.Min.Y)*cropH/float64(dstH))
		for x := r.Min.X; x < r.Max.X; x++ {
			sx := int(cropOffX + float64(x-r.Min.X)*cropW/float64(dstW))
			dst.Set(x, y, src.At(sx, sy))
		}
	}
}

// uuidToFilename renders a pgtype.UUID for use in a filename. The
// strings.NewReplacer guards against any future caller passing a
// non-canonical form — the canonical 8-4-4-4-12 hex form has no path
// separators or .. sequences, but defense-in-depth is cheap.
func uuidToFilename(u pgtype.UUID) string {
	s := pgtypeUUIDStringForCollage(u)
	return strings.NewReplacer("/", "_", "..", "_").Replace(s)
}

// pgtypeUUIDStringForCollage renders a pgtype.UUID as the canonical
// 8-4-4-4-12 hex form. Local helper to avoid pulling in a third-party
// UUID package or creating a cross-package dependency.
func pgtypeUUIDStringForCollage(u pgtype.UUID) string {
	if !u.Valid {
		return ""
	}
	b := u.Bytes
	return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
		b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
		b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15])
}
