// Periodic compactor for the library_changes log (#357 deferred
// follow-up). Every mutation in the library writes one row, so the
// table grows unbounded without a retention policy. This worker
// trims rows older than CompactorRetention on a daily tick.
//
// Why daily / 30 days: Flutter clients track a per-app cursor and
// pull deltas from the last seen id. As long as a client checks in
// at least once per retention window, it never sees the cursor go
// stale. Clients that drop offline longer than that hit the /api/
// library/sync 410 fallback and do a full resync. 30 days is the
// established norm for similar offline-cache delta logs and matches
// the spec at docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md.

package sync

import (
	"context"
	"log/slog"
	"time"

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

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

// CompactorRetention is the age past which library_changes rows are
// dropped. Mutable for tests; production runs should not change it
// without considering the corresponding client cursor-stale fallback.
var CompactorRetention = 30 * 24 * time.Hour

// CompactorInterval is how often the worker checks. Daily is enough —
// the table grows on the order of a few thousand rows/day for an
// active library, well below a size where intra-day trimming matters.
var CompactorInterval = 24 * time.Hour

// Compactor is a background worker that periodically deletes
// library_changes rows older than CompactorRetention.
type Compactor struct {
	pool     *pgxpool.Pool
	logger   *slog.Logger
	interval time.Duration
}

// NewCompactor builds a compactor with production defaults.
func NewCompactor(pool *pgxpool.Pool, logger *slog.Logger) *Compactor {
	return &Compactor{
		pool:     pool,
		logger:   logger,
		interval: CompactorInterval,
	}
}

// Run blocks until ctx is cancelled, ticking every c.interval.
// Fires one tick immediately on startup so a long-running process
// that hasn't been compacted in a while catches up.
func (c *Compactor) Run(ctx context.Context) {
	c.tickOnce(ctx)
	t := time.NewTicker(c.interval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			c.tickOnce(ctx)
		}
	}
}

// tickOnce deletes rows older than CompactorRetention and logs the
// row count. Errors are logged at WARN; we never abort the loop on
// transient DB failures.
func (c *Compactor) tickOnce(ctx context.Context) {
	cutoff := pgtype.Timestamptz{Time: time.Now().Add(-CompactorRetention), Valid: true}
	rows, err := dbq.New(c.pool).DeleteOldLibraryChanges(ctx, cutoff)
	if err != nil {
		c.logger.Warn("library_changes compactor: delete failed", "err", err)
		return
	}
	if rows > 0 {
		c.logger.Info("library_changes compactor: trimmed rows",
			"count", rows, "older_than", CompactorRetention)
	}
}
