package library

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"log/slog"
	"math"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

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

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

// audioExtensions is the v1 set. Duration extraction is not wired, so all of
// these get duration_ms=0 until an ffprobe / native-decoder pass lands.
var audioExtensions = map[string]bool{
	".mp3":  true,
	".m4a":  true,
	".flac": true,
	".ogg":  true,
}

type Stats struct {
	Scanned int `json:"scanned"`
	Added   int `json:"added"`
	Updated int `json:"updated"`
	Skipped int `json:"skipped"`
	Errored int `json:"errored"`
}

type Scanner struct {
	pool   *pgxpool.Pool
	logger *slog.Logger
	paths  []string
}

func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string) *Scanner {
	return &Scanner{pool: pool, logger: logger, paths: paths}
}

// Scan walks every configured root and upserts any audio file whose mtime is
// newer than the existing row's updated_at. Walk errors and per-file errors
// are logged + counted; the scan keeps going.
func (s *Scanner) Scan(ctx context.Context) (Stats, error) {
	var stats Stats
	q := dbq.New(s.pool)
	start := time.Now()

	for _, root := range s.paths {
		if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
			if ctx.Err() != nil {
				return fs.SkipAll
			}
			if err != nil {
				s.logger.Warn("library scan walk error", "path", path, "err", err)
				stats.Errored++
				return nil
			}
			if d.IsDir() {
				return nil
			}
			if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
				return nil
			}
			if err := s.scanFile(ctx, q, path, &stats); err != nil {
				s.logger.Warn("library scan file error", "path", path, "err", err)
				stats.Errored++
			}
			return nil
		}); err != nil {
			return stats, fmt.Errorf("library: walk %q: %w", root, err)
		}
	}

	s.logger.Info("library scan complete",
		"scanned", stats.Scanned,
		"added", stats.Added,
		"updated", stats.Updated,
		"skipped", stats.Skipped,
		"errored", stats.Errored,
		"duration_ms", time.Since(start).Milliseconds(),
	)
	if err := ctx.Err(); err != nil {
		return stats, err
	}
	return stats, nil
}

func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, stats *Stats) error {
	stats.Scanned++

	info, err := os.Stat(path)
	if err != nil {
		return fmt.Errorf("stat: %w", err)
	}
	mtime := info.ModTime()

	existing, err := q.GetTrackByPath(ctx, path)
	knownTrack := err == nil
	if err != nil && !errors.Is(err, pgx.ErrNoRows) {
		return fmt.Errorf("lookup: %w", err)
	}
	// Incremental skip: only when the file hasn't changed AND we already have
	// a real duration. The second clause lets older scans that recorded
	// duration_ms=0 (before ffprobe was wired) get backfilled without forcing
	// the operator to wipe the library. Once duration is set, subsequent
	// scans short-circuit as before.
	if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
		stats.Skipped++
		return nil
	}

	f, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open: %w", err)
	}
	defer func() { _ = f.Close() }()

	meta, err := tag.ReadFrom(f)
	if err != nil {
		return fmt.Errorf("tag read: %w", err)
	}

	artistName := meta.Artist()
	if artistName == "" {
		artistName = "Unknown Artist"
	}
	albumTitle := meta.Album()
	if albumTitle == "" {
		albumTitle = "Unknown Album"
	}
	trackTitle := meta.Title()
	if trackTitle == "" {
		trackTitle = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
	}

	artist, err := s.resolveArtist(ctx, q, artistName)
	if err != nil {
		return fmt.Errorf("artist: %w", err)
	}
	album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year())
	if err != nil {
		return fmt.Errorf("album: %w", err)
	}

	trackNum, _ := meta.Track()
	discNum, _ := meta.Disc()
	durationMs, err := probeDurationMs(ctx, path)
	if err != nil {
		// Missing duration is degraded UX (clients can't scrub) but not a
		// blocker for ingestion. Record the file with 0ms; the next scan
		// will retry via the backfill clause in the skip check above.
		s.logger.Warn("library scan: ffprobe failed", "path", path, "err", err)
		durationMs = 0
	}
	params := dbq.UpsertTrackParams{
		Title:      trackTitle,
		AlbumID:    album.ID,
		ArtistID:   artist.ID,
		DurationMs: durationMs,
		FilePath:   path,
		FileSize:   info.Size(),
		FileFormat: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."),
	}
	if trackNum > 0 {
		v := int32(trackNum)
		params.TrackNumber = &v
	}
	if discNum > 0 {
		v := int32(discNum)
		params.DiscNumber = &v
	}
	if g := meta.Genre(); g != "" {
		params.Genre = &g
	}

	if _, err := q.UpsertTrack(ctx, params); err != nil {
		return fmt.Errorf("upsert track: %w", err)
	}

	if knownTrack {
		stats.Updated++
	} else {
		stats.Added++
	}
	return nil
}

func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name string) (dbq.Artist, error) {
	existing, err := q.GetArtistByName(ctx, name)
	if err == nil {
		return existing, nil
	}
	if !errors.Is(err, pgx.ErrNoRows) {
		return dbq.Artist{}, err
	}
	return q.UpsertArtist(ctx, dbq.UpsertArtistParams{
		Name:     name,
		SortName: sortKey(name),
	})
}

func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgtype.UUID, title string, year int) (dbq.Album, error) {
	existing, err := q.GetAlbumByArtistAndTitle(ctx, dbq.GetAlbumByArtistAndTitleParams{ArtistID: artistID, Title: title})
	if err == nil {
		return existing, nil
	}
	if !errors.Is(err, pgx.ErrNoRows) {
		return dbq.Album{}, err
	}
	params := dbq.UpsertAlbumParams{
		Title:     title,
		SortTitle: sortKey(title),
		ArtistID:  artistID,
	}
	if d, ok := releaseDateFromYear(year); ok {
		params.ReleaseDate = d
	} else if year != 0 {
		// year=0 is the no-tag case; anything else getting rejected is a tag
		// we couldn't trust (typo, OOB number, …). Log it so users can chase
		// down the file but don't fail the album insert over a soft field.
		s.logger.Warn("library scan: dropping invalid release year",
			"year", year, "album", title)
	}
	return q.UpsertAlbum(ctx, params)
}

// releaseDateFromYear converts a tag-supplied year into a Postgres date,
// returning ok=false if the year is outside what we'll accept. We're
// deliberately strict (1..9999): Postgres' date type goes much wider, but
// 5-digit years from ID3 tags are always garbage in practice and trigger
// SQLSTATE 22008 (datetime field overflow) at insert time.
func releaseDateFromYear(year int) (pgtype.Date, bool) {
	if year < 1 || year > 9999 {
		return pgtype.Date{}, false
	}
	return pgtype.Date{
		Time:  time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC),
		Valid: true,
	}, true
}

// sortKey drops a leading "The " for sortable ordering. Non-English articles
// (Los, Die, Les) can be added if users ask — keeping the rule obvious for now.
func sortKey(s string) string {
	if len(s) >= 4 && strings.EqualFold(s[:4], "the ") {
		return s[4:]
	}
	return s
}

// probeTimeout bounds how long ffprobe is allowed to inspect a single file.
// 10s is generous for local mp3/flac — hits are usually <100ms — but caps
// blast radius if a pathological file or a slow network mount stalls a scan.
const probeTimeout = 10 * time.Second

// probeDurationMs shells out to ffprobe to extract a track's duration. We
// rely on ffmpeg being in the image (see Dockerfile). The CLI is slow per
// call (fork+exec) but scans are batch-mode; this is simpler than pulling
// a Go-side decoder library and handles every format ffmpeg does.
func probeDurationMs(ctx context.Context, path string) (int32, error) {
	probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
	defer cancel()
	cmd := exec.CommandContext(probeCtx, "ffprobe",
		"-v", "error",
		"-show_entries", "format=duration",
		"-of", "default=noprint_wrappers=1:nokey=1",
		path,
	)
	out, err := cmd.Output()
	if err != nil {
		return 0, fmt.Errorf("ffprobe: %w", err)
	}
	seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
	if err != nil {
		return 0, fmt.Errorf("parse ffprobe output %q: %w", out, err)
	}
	if seconds <= 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) {
		return 0, fmt.Errorf("invalid duration: %v", seconds)
	}
	ms := seconds * 1000
	if ms > math.MaxInt32 {
		ms = math.MaxInt32
	}
	return int32(ms), nil
}
