package library

import (
	"context"
	"errors"
	"fmt"
	"os"

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

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

// trackReconciler is the slice of dbq.Queries reconcileMissing needs. Narrowed
// to an interface so the guard logic — which is the part that can do damage —
// is unit-testable against a fake without a database.
type trackReconciler interface {
	ListTrackPathsForReconcile(ctx context.Context) ([]dbq.ListTrackPathsForReconcileRow, error)
	MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
	ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
}

// Reconcile marks tracks whose files have disappeared (#2523).
//
// Why this exists: nothing in Minstrel used to notice a deleted file. The walk
// only visits paths that exist, so a row whose file is gone was never scanned,
// never errored, never counted — permanently invisible. The watcher ignores
// removals by design (see classifyEvent), and the safety-net scan is the same
// walk, so it covers additions only. Rows accumulated forever, kept being
// offered to recommendations, and failed at playback.
//
// Why it MARKS rather than deletes: a missing file is a claim about the
// filesystem, and the filesystem lies transiently — an unmounted volume, a
// network-storage blip, a container that started before its media mount
// attached. Every other sweep in this codebase (internal/gc) resolves a truth
// *inside* the database and is safe to run blind. This one isn't, so the
// destructive step is deliberately not here. Marking is reversible: the next
// good scan clears it.

// missingMarkMaxFraction caps how much of the library one reconcile may newly
// mark missing. A partially-attached mount is the failure this defends against:
// the roots resolve, the walk succeeds, and it legitimately sees only part of
// the library — evidence indistinguishable from a mass deletion.
//
// A quarter is deliberately conservative. A genuine bulk deletion trips it and
// gets logged rather than applied, which needs a second scan (or operator
// action) to take effect. That's the right trade: the cost of over-refusing is
// a stale row and a log line, and the cost of over-marking is a chunk of the
// library silently vanishing from every mix.
const missingMarkMaxFraction = 0.25

// reconcileMissing diffs the paths the walk saw against every row in the table.
// Rows not seen get marked; rows seen that carry a mark get cleared.
//
// seen must come from a COMPLETE walk of every configured root. Callers with a
// partial view must not call this.
func (s *Scanner) reconcileMissing(
	ctx context.Context, q trackReconciler, seen map[string]struct{}, stats *Stats,
) error {
	if err := s.verifyRootsPresent(); err != nil {
		return err
	}
	// Roots resolved but the walk found nothing. Either the library is genuinely
	// empty — in which case there is nothing to reconcile — or the mount is
	// hollow. Both mean: don't act.
	if len(seen) == 0 {
		return errors.New("walk saw no audio files; refusing to reconcile")
	}

	rows, err := q.ListTrackPathsForReconcile(ctx)
	if err != nil {
		return fmt.Errorf("list track paths: %w", err)
	}
	if len(rows) == 0 {
		return nil
	}

	var toMark, toClear []pgtype.UUID
	for _, row := range rows {
		_, present := seen[row.FilePath]
		switch {
		case !present && !row.MissingSince.Valid:
			toMark = append(toMark, row.ID)
		case present && row.MissingSince.Valid:
			toClear = append(toClear, row.ID)
		}
	}

	// Clear before marking, and unconditionally. Restoring a file is never the
	// dangerous direction, so it must not be blocked by the guard below —
	// otherwise a library that tripped the cap once could never recover its
	// marks even after the mount came back.
	if len(toClear) > 0 {
		n, err := q.ClearTracksMissing(ctx, toClear)
		if err != nil {
			return fmt.Errorf("clear missing marks: %w", err)
		}
		stats.Restored = int(n)
		s.logger.Info("library scan: files returned", "count", n)
	}

	if len(toMark) == 0 {
		return nil
	}
	if fraction := float64(len(toMark)) / float64(len(rows)); fraction > missingMarkMaxFraction {
		return fmt.Errorf(
			"refusing to mark %d of %d tracks missing (%.0f%% > %.0f%% cap): "+
				"this looks like an unavailable mount rather than a deletion",
			len(toMark), len(rows), fraction*100, missingMarkMaxFraction*100,
		)
	}

	n, err := q.MarkTracksMissing(ctx, toMark)
	if err != nil {
		return fmt.Errorf("mark tracks missing: %w", err)
	}
	stats.Missing = int(n)
	// Warn, not Info: every one of these is a library entry the operator
	// probably didn't intend to lose, and the only place it surfaces today is
	// this line.
	s.logger.Warn("library scan: tracks marked missing (files not found)",
		"count", n, "library_total", len(rows))
	return nil
}

// verifyRootsPresent is the first and most important guard. If a configured root
// doesn't resolve to a readable directory, the walk beneath it found nothing and
// every row under it would look deleted. An unmounted media volume is the
// obvious case, and it is common enough — a container restart racing its volume
// mount does exactly this.
func (s *Scanner) verifyRootsPresent() error {
	if len(s.paths) == 0 {
		return errors.New("no scan roots configured")
	}
	for _, root := range s.paths {
		info, err := os.Stat(root)
		if err != nil {
			return fmt.Errorf("scan root %q unavailable: %w", root, err)
		}
		if !info.IsDir() {
			return fmt.Errorf("scan root %q is not a directory", root)
		}
		entries, err := os.ReadDir(root)
		if err != nil {
			return fmt.Errorf("scan root %q unreadable: %w", root, err)
		}
		// An empty root is the signature of a mount point with nothing mounted
		// on it. `os.Stat` succeeds on the bare directory, so this is the only
		// cheap way to tell the two apart.
		if len(entries) == 0 {
			return fmt.Errorf("scan root %q is empty; refusing to reconcile", root)
		}
	}
	return nil
}
