// Package backend contains the main application logic.
package backend

//go:generate go tool templ generate

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"path/filepath"
	"sync/atomic"
	"time"

	"github.com/wailsapp/wails/v3/pkg/application"

	"yellowjacket/backend/assets"
	"yellowjacket/backend/autotagservice"
	"yellowjacket/backend/config"
	"yellowjacket/backend/coverart"
	"yellowjacket/backend/database"
	"yellowjacket/backend/download"
	"yellowjacket/backend/events"
	"yellowjacket/backend/explore"
	"yellowjacket/backend/frontendutil"
	"yellowjacket/backend/home"
	"yellowjacket/backend/jobs"
	"yellowjacket/backend/library"
	"yellowjacket/backend/maintenance"
	"yellowjacket/backend/mediacontrols"
	"yellowjacket/backend/player"
	"yellowjacket/backend/playlist"
	"yellowjacket/backend/profiling"
	"yellowjacket/backend/queue"
	"yellowjacket/backend/system"
	"yellowjacket/backend/tagwriter"
	"yellowjacket/backend/testctl"
)

// YellowJacketApp is the main application struct for Wails.
type YellowJacketApp struct {
	// Services is what v3 binds to the frontend.  Each entry's
	// ServiceStartup runs before the app-level wiring in OnStartup.
	Services     []application.Service
	FrontendUtil *frontendutil.FrontendUtil

	logger        *slog.Logger
	assetHandler  *assets.Handler
	database      *database.DB
	library       *library.Library
	player        *player.Player
	playlist      *playlist.Service
	queue         *queue.Queue
	explore       *explore.Service
	autotag       *autotagservice.Service
	downloads     *download.Manager
	downloadSvc   *download.Service
	wanted        *download.Reconciler
	jobs          *jobs.Registry
	mediaControls mediacontrols.Handler
	tagWriter     *tagwriter.TagWriter
	janitor       *maintenance.Runner
	appContext    context.Context
	appConfig     *config.Config
	startupErr    error

	// quitAsking guards the one quit-confirmation dialog; quitConfirmed
	// records that the user already answered "quit anyway", so the
	// Quit() issued from that callback is not questioned again.
	quitAsking    atomic.Bool
	quitConfirmed atomic.Bool
}

// NewYellowJacketApp creates and initializes the application.
func NewYellowJacketApp(
	logger *slog.Logger,
	assetHandler *assets.Handler,
) (*YellowJacketApp, error) {
	defer profiling.TimeOp(logger, "app.NewYellowJacketApp")()

	// initialize anything that does not need access to the wails runtime here
	yjApp := &YellowJacketApp{
		logger:       logger,
		assetHandler: assetHandler,
		appContext:   context.Background(),
		janitor:      maintenance.NewRunner(logger),
	}

	// create database
	db, err := database.NewDB(logger)
	if err != nil {
		return nil, fmt.Errorf("could not connect to local database: %w", err)
	}

	yjApp.database = db

	// create config
	appConfig, err := config.NewConfig(yjApp.logger)
	if err != nil {
		return nil, fmt.Errorf("could not get config: %w", err)
	}

	yjApp.appConfig = appConfig

	// create frontendUtil
	feUtil, err := frontendutil.NewFrontendUtil()
	if err != nil {
		return nil, fmt.Errorf("could not create frontendUtil: %w", err)
	}

	yjApp.FrontendUtil = feUtil

	lib, err := library.NewLibrary(
		yjApp.appContext,
		yjApp.logger,
		yjApp.appConfig.Library,
		yjApp.database,
	)
	if err != nil {
		return nil, fmt.Errorf("could not create library: %w", err)
	}

	yjApp.library = lib

	// create cover art handler
	coverHandler, err := coverart.NewHandler()
	if err != nil {
		return nil, fmt.Errorf("could not create cover art handler: %w", err)
	}

	yjApp.assetHandler.RegisterHandler(coverart.PathPrefix, coverHandler)

	// Register artist image handler for serving cached artist photos.
	artistImgDir, err := system.GetUserDataDirPath()
	if err == nil {
		artistImgHandler := http.StripPrefix(
			"/artist-images/",
			http.FileServer(http.Dir(filepath.Join(artistImgDir, "artist-images"))),
		)

		yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler)
	}

	// Dev-only /__test/ control surface: the residue of harness work the
	// browser cannot reach (snapshot/restore the DB mid-run, force a
	// backend event).  Compiled out of non-dev builds entirely, and even
	// in a dev build it registers nothing unless YJ_TESTCTL=1.  The
	// context is read lazily because it only exists after OnStartup.
	testctl.Register(yjApp.assetHandler, testctl.Deps{
		Logger:  logger,
		DB:      yjApp.database,
		Context: func() context.Context { return yjApp.appContext },
	})

	// create playlist service
	yjApp.playlist = playlist.NewService(
		yjApp.logger, yjApp.database, yjApp.appConfig,
	)
	yjApp.playlist.SetFavoritesConfig(yjApp.appConfig)

	// create queue (before wails.Run so it can be bound)
	yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database)

	// create player (before wails.Run so it can be bound;
	// speaker hardware is initialized later in OnStartup)
	yjApp.player = player.NewPlayer(
		yjApp.logger.WithGroup("player"), yjApp.database,
	)

	// create tag writer
	yjApp.tagWriter = tagwriter.NewTagWriter(
		yjApp.logger,
		yjApp.database,
		&playerAdapter{p: yjApp.player},
		yjApp.library,
	)

	// create explore service
	yjApp.explore = explore.NewExploreService(
		yjApp.logger.WithGroup("explore"), yjApp.database,
	)

	// create the background job registry and wire it into the
	// subsystems that run long jobs, so scans and index builds all
	// report through one surface.
	yjApp.jobs = jobs.NewRegistry(
		yjApp.logger.WithGroup("jobs"),
		jobs.NewStore(yjApp.database, yjApp.logger.WithGroup("jobs")),
	)
	yjApp.library.SetJobRegistry(yjApp.jobs)
	yjApp.explore.SetJobRegistry(yjApp.jobs)

	// Whether this connection is one to spend ~0.6 GB of catalog on
	// (plan 016 B4). The probe is injected from here because `explore` is
	// imported by `cmd/indexbuild`, which must not link Wails: naming
	// `application` there is what `TestIndexToolsDoNotImportWails`
	// forbids.
	//
	// `application.Mobile`, not `application.Android`: the latter exists
	// only under the `android` build tag, while `Mobile` is the portable
	// name whose desktop implementation is a stub returning "" — which
	// parses to "unknown" and refuses nothing. Plan 016 named the tagged
	// one; this is the same call by the name every build has.
	yjApp.explore.SetNetworkPolicy(
		func() explore.Network {
			return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
		},
		yjApp.appConfig.GetAllowMeteredCatalogDownload,
	)

	// Let the release prefetch skip albums the user already owns in
	// full — those open with no catalog call at all, so warming their
	// tracklists spends the most expensive request in the app on
	// nothing.  Injected because neither package imports the other.
	yjApp.explore.SetAlbumComplete(func(albumID int64) bool {
		c, err := yjApp.library.GetAlbumCompleteness(albumID)

		return err == nil && c.Known && c.Complete
	})

	// create autotag service (depends on explore + tagWriter)
	yjApp.autotag = autotagservice.NewService(
		yjApp.logger.WithGroup("autotag"),
		yjApp.database,
		yjApp.explore,
		yjApp.tagWriter,
	)
	yjApp.autotag.SetJobRegistry(yjApp.jobs)

	// Create the download subsystem.  Acquiring music is optional: a
	// failure here (unwritable data dir, say) must not stop the app
	// from playing the library the user already has, so it is logged
	// and the feature stays unavailable rather than fatal.
	if err := yjApp.initDownloads(); err != nil {
		yjApp.logger.Error(
			"download clients unavailable", "error", err,
		)
	}

	// application.NewService is generic over a concrete pointer type —
	// the static analyser that generates bindings reads these calls, so
	// a []any of the same values would generate nothing.
	yjApp.Services = []application.Service{
		application.NewService(yjApp.FrontendUtil),
		application.NewService(yjApp.appConfig),
		application.NewService(yjApp.library),
		application.NewService(yjApp.playlist),
		application.NewService(yjApp.queue),
		application.NewService(yjApp.player),
		application.NewService(yjApp.tagWriter),
		application.NewService(yjApp.explore),
		application.NewService(yjApp.autotag),
		application.NewService(jobs.NewService(yjApp.jobs)),
		application.NewService(home.NewService(
			yjApp.logger.WithGroup("home"),
			yjApp.database,
			yjApp.library,
		)),
	}

	if yjApp.downloadSvc != nil {
		yjApp.Services = append(
			yjApp.Services, application.NewService(yjApp.downloadSvc),
		)
	}

	// Last, deliberately: services start in registration order, so this
	// runs once every service above has taken its context.  See
	// startup.go for why the wiring is a service rather than an
	// application-event hook.
	yjApp.Services = append(
		yjApp.Services,
		application.NewService(&startupService{app: yjApp}),
	)

	return yjApp, nil
}

// initDownloads builds the download subsystem: staging area, secret
// store, importer and manager, plus the Wails-bound service.
func (yj *YellowJacketApp) initDownloads() error {
	logger := yj.logger.WithGroup("download")

	staging, err := download.NewStaging(logger)
	if err != nil {
		return fmt.Errorf("could not create download staging: %w", err)
	}

	secrets, err := download.NewFileSecretStore()
	if err != nil {
		return fmt.Errorf("could not create download secret store: %w", err)
	}

	store := download.NewStore(yj.database)

	importer := download.NewImporter(logger, staging, yj.tagWriter, yj.library)

	yj.downloads = download.NewManager(
		logger, store, secrets, staging, importer, yj.library,
	)
	yj.downloads.SetJobRegistry(yj.jobs)

	yj.downloadSvc = download.NewService(logger, yj.downloads, store, secrets)

	// The wanted list needs the explore index to know what an artist
	// released and what the library already owns, so it is wired here
	// where both exist.  The reconcile loop itself is not started until
	// the Wails runtime is up.
	yj.wanted = download.NewReconciler(
		logger, store, yj.downloads, newExploreCatalog(yj.explore),
	)
	yj.downloadSvc.SetReconciler(yj.wanted)

	return nil
}

// playerAdapter wraps *player.Player to satisfy the tagwriter.PlayerStopper
// interface, breaking the import cycle between tagwriter and player.
type playerAdapter struct{ p *player.Player }

func (a *playerAdapter) CurrentFilePath() string {
	return a.p.GetCurrentTrackInfo().FilePath
}

func (a *playerAdapter) StopAndRelease() { a.p.UnloadTrack() }

// initDownloadRuntime brings the download subsystem up once the Wails
// runtime exists: it applies the user's import layout, builds providers
// from stored config, and clears staging left by a previous run.
//
// Provider construction and the sweep both touch the network and the
// filesystem, so they run in the background — a slow or unreachable
// download client must not delay the window appearing.
func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
	cfg := yj.appConfig.Downloads
	if cfg == nil {
		cfg = &download.UserConfig{}
		cfg.ApplyDefaults()
	}

	yj.downloads.SetImportOptions(download.ImportOptions{
		PathTemplate: cfg.PathTemplate,
	})
	yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
	yj.downloads.SetPreferences(cfg.AutoDownloadPrefs())

	go func() {
		if err := yj.downloads.Reload(ctx); err != nil {
			yj.logger.Warn("could not load download providers", "error", err)
		}

		yj.downloads.Sweep(ctx)
	}()

	if yj.wanted == nil {
		return
	}

	yj.wanted.SetInterval(cfg.WantedInterval())
	yj.wanted.SetBatch(cfg.WantedBatch)
	yj.wanted.SetOnChange(func() {
		events.Emit(ctx, events.RequestsChanged)
	})
	yj.wanted.Start(ctx)
}

// WindowConfig returns the window configuration for use by the host.
func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
	return yj.appConfig.Window
}

// OnStartup wires the services to each other once the runtime exists.
//
// It is no longer where each service *gets* the context: every bound
// service implements v3's ServiceStartup, which the runtime calls
// before this runs.  What is left here is the cross-service wiring —
// hooks, adapters and the callbacks that make one package drive
// another — which has no home inside any single service.
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
	defer profiling.TimeOp(yj.logger, "app.OnStartup")()

	yj.appContext = ctx

	yj.playlist.EnsureDefaultPlaylist()
	// Recover playlists that lost tracks from a pre-fix FullRescan.
	go yj.playlist.RepopulateFromM3U()
	// Backfill snapshots for smart playlists created before
	// creation-time materialization existed.
	go yj.playlist.MaterializeUnmaterializedSmartPlaylists()

	// Initialize speaker hardware (player struct created in
	// NewYellowJacketApp for Wails binding registration).
	if err := yj.player.InitSpeaker(); err != nil {
		yj.startupErr = errors.Join(
			yj.startupErr,
			fmt.Errorf("could not initialize speaker: %w", err),
		)
	}

	// The job registry is not a bound service — it is wrapped by
	// jobs.NewService for that — so it still takes the context by hand.
	yj.jobs.SetContext(ctx)

	if yj.downloadSvc != nil {
		yj.initDownloadRuntime(ctx)
	}

	// Bring back jobs the user paused before the last shutdown, still
	// paused.  Must run before the soft scan in OnDomReady, which
	// checks these records so it does not restart a paused library.
	yj.library.RestorePausedScans()
	yj.explore.AdoptPausedIndexBuild()

	yj.queue.SetPlayer(yj.player)
	yj.queue.SetFallbackSource(&queueFallbackAdapter{
		config:   yj.appConfig,
		playlist: yj.playlist,
		explore:  yj.explore,
	})
	yj.queue.RestoreState()

	// Wire cross-cutting rescan hooks so the library can
	// orchestrate queue clearing and playlist restoration
	// without depending on those packages directly.
	yj.library.SetRescanHooks(library.RescanHooks{
		PreClear: func() {
			yj.queue.Clear()
			// Stop the search index build so it doesn't fight
			// with the rescan for DB access.
			yj.explore.StopIndexBuild()
		},
		PostScan: func() {
			yj.playlist.RestoreAllPlaylists()
			// DON'T restart the index build here — queued
			// library scans may still be running.  The index
			// build starts after ALL scans complete (via the
			// scan hooks below).
		},
	})

	// Wire scan hooks so the playlist service can resolve
	// phantom tracks after each library scan completes.
	yj.library.SetScanHooks(library.ScanHooks{
		RepopulatePlaylists: yj.playlist.RepopulateFromM3U,
		ResolvePhantoms:     yj.playlist.ResolvePhantomTracksAfterScan,
		OnAllScansComplete: func() {
			// Fold the local library into the search index: every
			// MB-verified owned artist/album/track is upserted and
			// flagged in_library, straight from the library tables
			// with no API calls.  Deep discographies stay lazy.
			yj.explore.PopulateLocalCrossReferences()

			// Enrich any owned artists whose discography hasn't been
			// fetched yet so their wider catalogue is searchable offline
			// right after the scan.  Background, bounded, resumable, and a
			// no-op once every owned artist is covered.
			yj.explore.BackfillLibraryDiscographies()

			// Resolve any release-group MBIDs the scan could only find a
			// release-level tag for (see updateMBIDs). Same shape as the
			// discography backfill above: background, bounded, resumable.
			yj.explore.BackfillReleaseGroupMBIDs()

			// Start (or resume) the dump-based index build.  Skips
			// itself once the one-time import has completed, so this
			// is cheap on every startup.
			yj.explore.StartIndexBuild()

			// Fold in any new incremental listen dumps to keep
			// popularity fresh (weekly-gated, background, no API).
			// No-op while the full import above is still running.
			yj.explore.RefreshListenCounts()

			// Refresh the lyric-search FTS index from the just-scanned
			// library, then backfill any missing lyrics from LRCLIB in
			// the background (bounded, resumable, idempotent).
			yj.explore.RebuildLyricsIndex()
			yj.explore.BackfillLibraryLyrics()

			// Sweep the autotag queue for newly-discovered pending
			// items so the user sees match scores ready when they
			// next open the review page.  The worker is idempotent
			// (skips items that already have a score) and yields
			// to foreground review activity.
			yj.autotag.StartBackgroundPrefetch()
		},
	})

	// Wire removal hooks so the library can stop playback and
	// compact the queue during library removal without depending
	// on the player or queue packages directly.
	yj.library.SetRemovalHooks(library.RemovalHooks{
		StopPlayback: func() { yj.player.UnloadTrack() },
		CompactQueue: yj.queue.CompactAfterLibraryRemoval,
		// Removal deletes owned content outside a scan, so force the
		// gated library-sync steps to re-run on the next launch and
		// clear stale in_library flags / orphaned lyric-index rows.
		PostRemove: yj.explore.InvalidateLibrarySync,
	})

	// Register playback finished handler to drive queue auto-advance.
	yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)

	// Initialize OS media controls (MPRIS on desktop Linux, a
	// MediaSession on Android, no-op elsewhere). The callbacks are the
	// same on every platform; only what delivers them differs.
	yj.mediaControls = mediacontrols.NewHandler(yj.logger)

	if err := yj.mediaControls.Init(mediacontrols.Callbacks{
		OnPlay: yj.queue.Play,
		OnPause: func() {
			if err := yj.player.Pause(); err != nil {
				yj.logger.Warn("Media controls Pause failed", "err", err)
			}
		},
		OnPlayPause: func() {
			if yj.player.IsPlaying() {
				if err := yj.player.Pause(); err != nil {
					yj.logger.Warn(
						"Media controls PlayPause(pause) failed", "err", err,
					)
				}
			} else {
				yj.queue.Play()
			}
		},
		OnStop: func() {
			if err := yj.player.Pause(); err != nil {
				yj.logger.Warn("Media controls Stop failed", "err", err)
			}
		},
		OnNext:     yj.queue.Next,
		OnPrevious: yj.queue.Previous,
		OnSeek: func(positionSec int) {
			if err := yj.player.Seek(positionSec); err != nil {
				yj.logger.Warn("Media controls Seek failed", "err", err)
			}
		},
		OnVolume: func(vol float64) {
			yj.player.SetVolume(
				player.UserVolume(
					vol * float64(player.MaxUserVol),
				),
			)
		},
		OnDuck: yj.player.SetDuck,
	}); err != nil {
		yj.logger.Error(
			"Failed to initialize media controls",
			"err", err,
		)
	}

	yj.player.SetMediaControls(yj.mediaControls)
}

// SaveWindowState captures the window's size while the window is still
// alive.  It is registered on the WindowClosing event, because at
// shutdown there is no window left to measure.
func (yj *YellowJacketApp) SaveWindowState(window application.Window) {
	if window == nil {
		return
	}

	w, h := window.Size()

	// Guard against a bogus size clobbering a good saved one.  During
	// teardown / hot-reload the runtime can report a zero or below-
	// minimum size; persisting that would shrink the window to the
	// minimum on next launch.  Keep the previously-saved size instead.
	if w < config.MinWidth || h < config.MinHeight {
		yj.logger.Warn("window close: ignoring bogus window size",
			"width", w,
			"height", h,
			"kept_width", yj.appConfig.Window.Width,
			"kept_height", yj.appConfig.Window.Height,
		)

		return
	}

	yj.logger.Info("window close: saving window state",
		"width", w,
		"height", h,
		"accentColor", yj.appConfig.Theme.AccentColor,
		"backgroundShade", yj.appConfig.Theme.BackgroundShade,
	)

	yj.appConfig.Window.Width = w
	yj.appConfig.Window.Height = h

	if err := yj.appConfig.Save(); err != nil {
		yj.logger.Error(
			"Failed to save window state",
			"err", err,
		)
	}
}

// ShouldQuit answers v3's quit veto: false keeps the app running.
//
// Quitting mid-apply cancels the service context and leaves a folder
// half-retagged with nothing recording where it stopped (errors.p4),
// which is the one case worth interrupting a quit for.
//
// The shape differs from v2's OnBeforeClose because v3's dialog is
// asynchronous — Show() returns immediately and the answer arrives on
// a button callback — so this cannot ask and answer in one call.  It
// vetoes the quit, asks, and quits again from the callback if the user
// says so.  quitConfirmed is what stops that second Quit() coming
// straight back here and asking a second time.
func (yj *YellowJacketApp) ShouldQuit() bool {
	if yj.quitConfirmed.Load() {
		return true
	}

	if yj.autotag == nil || !yj.autotag.WritesInFlight() {
		return true
	}

	// A dialog already up must not spawn another on every close attempt.
	if !yj.quitAsking.CompareAndSwap(false, true) {
		return false
	}

	app := application.Get()
	if app == nil {
		// No runtime to ask through: never trap the user in the app.
		return true
	}

	dialog := app.Dialog.Question()
	dialog.SetTitle("Tags are still being written")
	dialog.SetMessage(
		"YellowJacket is rewriting tags on your files. " +
			"Quitting now leaves that folder holding a mix of old and " +
			"new tags.\n\nQuit anyway?",
	)

	quit := dialog.AddButton("Quit anyway")
	quit.OnClick(func() {
		yj.quitConfirmed.Store(true)
		yj.quitAsking.Store(false)
		app.Quit()
	})

	stay := dialog.AddButton("Keep writing")
	stay.OnClick(func() { yj.quitAsking.Store(false) })
	stay.SetAsDefault()
	stay.SetAsCancel()

	dialog.Show()

	return false
}

// OnShutdown saves player state and cleans up resources before the
// application exits.  v3 passes no context — the app is going away, so
// there is nothing left to scope work to.
func (yj *YellowJacketApp) OnShutdown() {
	if yj.player != nil {
		yj.player.SaveState()
	}

	if yj.queue != nil {
		yj.queue.SaveState()
	}

	if yj.mediaControls != nil {
		yj.mediaControls.Close()
	}
}

// OnDomReady handles post-DOM initialization and startup error reporting.
// State synchronisation (player volume, track info, queue contents) is
// driven by the frontend: once its stores have registered their event
// listeners, index.ts calls Player.EmitCurrentState() and
// Queue.EmitCurrentState() via Wails bindings.
func (yj *YellowJacketApp) OnDomReady(_ context.Context) {
	if yj.startupErr != nil {
		yj.logger.Error("startup error", "err", yj.startupErr.Error())

		// A startup failure is not a mid-write quit, so go straight out
		// rather than through the ShouldQuit question.
		yj.quitConfirmed.Store(true)

		if app := application.Get(); app != nil {
			app.Quit()
		}

		return
	}

	// Soft scan: compare file counts on disk vs DB for each library.
	// Only libraries with mismatched counts get a full scan — unchanged
	// libraries are silently skipped (no progress bar, no UI noise).
	go func() {
		if err := yj.library.SoftScanAllLibraries(); err != nil {
			yj.logger.Error("soft scan failed", "err", err)
		}

		// If no scans were queued (library unchanged), start the
		// index build directly.  If scans WERE queued, the
		// OnAllScansComplete hook starts it after they finish.
		if yj.library.GetScanQueueLength() == 0 && !yj.library.IsScanActive() {
			// Keep the search index's in_library flags and owned-entity
			// rows in sync.  Gated: the library is unchanged here, so
			// this only does work on the first launch after an upgrade
			// or index wipe — steady-state launches skip the write burst.
			yj.explore.PopulateLocalCrossReferencesIfNeeded()

			yj.explore.StartIndexBuild()

			// Weekly-gated incremental popularity refresh (background,
			// no API). No-op while the full import is running.
			yj.explore.RefreshListenCounts()

			// Same for the lyric-search index.  The backfill keeps it in
			// sync incrementally, so on an unchanged library the full
			// rebuild is redundant and gated out; the backfill still runs
			// to fill any remaining gaps.
			yj.explore.RebuildLyricsIndexIfNeeded()
			yj.explore.BackfillLibraryLyrics()

			// Continue enriching any owned artists still missing their
			// discography (e.g. a prior run was capped or interrupted).
			// Cheap no-op once every owned artist is covered.
			yj.explore.BackfillLibraryDiscographies()

			// Same continuation for release-group MBID resolution.
			yj.explore.BackfillReleaseGroupMBIDs()
		}

		// Kick off the autotag prefetch worker so any unscored
		// pending items get their match scores filled in while
		// the user does other things.  Idempotent — re-running on
		// every app launch is fine; previously-scored items are
		// skipped (the worker filters score IS NULL).
		yj.autotag.StartBackgroundPrefetch()

		// Start the janitor last: its sweeps compare against live data,
		// so running them after the scan and index work has settled
		// avoids deleting something a running import is about to
		// reference.  Each job enforces its own minimum interval, so the
		// daily tick is a cheap no-op most of the time.
		yj.startJanitor()
	}()
}

// janitorTick is how often the maintenance runner wakes up.  Individual
// jobs enforce their own minimum intervals, so most ticks do nothing.
const janitorTick = 6 * time.Hour

// startJanitor registers the maintenance jobs and starts the background
// runner.  Every job is registered here rather than at each package's
// init, so the full set of janitorial work is one visible list — a cache
// that forgets to register is missing from this function, which is
// harder to overlook than a function nobody calls.
func (yj *YellowJacketApp) startJanitor() {
	coversDir, err := coverart.CoversDir()
	if err != nil {
		yj.logger.Warn("janitor: could not resolve covers directory",
			"err", err)

		return
	}

	dataDir, err := system.GetUserDataDirPath()
	if err != nil {
		yj.logger.Warn("janitor: could not resolve user data directory",
			"err", err)

		return
	}

	yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
	yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
		yj.database, coversDir, library.CoverArtFileSet,
	))
	yj.janitor.Register(maintenance.OrphanedArtistImagesJob(
		yj.database,
		filepath.Join(dataDir, explore.ArtistImageDirName),
		explore.ArtistImageDir,
	))
	yj.janitor.Register(maintenance.StrayArtistImageFilesJob(
		filepath.Join(dataDir, explore.ArtistImageDirName),
		explore.ArtistImageKeepNames(),
	))
	yj.janitor.Register(maintenance.ExpiredProxyCacheJob(
		filepath.Join(dataDir, explore.CoverArtCacheDirName),
	))

	yj.logger.Info("janitor started", "jobs", yj.janitor.JobNames())

	yj.janitor.Start(yj.appContext, janitorTick)
}
