package library

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"testing"

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

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

// fakeReconciler records what reconcileMissing decided to do, so the guards can
// be tested without a database. The guards are the whole point of this pass —
// they are what stands between an unmounted volume and the library disappearing
// from every mix — so they get tested directly rather than via integration.
type fakeReconciler struct {
	rows     []dbq.ListTrackPathsForReconcileRow
	marked   []pgtype.UUID
	cleared  []pgtype.UUID
	listErr  error
	markErr  error
	clearErr error
}

func (f *fakeReconciler) ListTrackPathsForReconcile(context.Context) ([]dbq.ListTrackPathsForReconcileRow, error) {
	return f.rows, f.listErr
}

func (f *fakeReconciler) MarkTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
	if f.markErr != nil {
		return 0, f.markErr
	}
	f.marked = append(f.marked, ids...)
	return int64(len(ids)), nil
}

func (f *fakeReconciler) ClearTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
	if f.clearErr != nil {
		return 0, f.clearErr
	}
	f.cleared = append(f.cleared, ids...)
	return int64(len(ids)), nil
}

// Compile-time proof the real queries still satisfy what reconcile needs — the
// interface exists to narrow dbq.Queries, not to diverge from it.
var _ trackReconciler = (*dbq.Queries)(nil)

func testUUID(n byte) pgtype.UUID {
	var u pgtype.UUID
	u.Bytes[15] = n
	u.Valid = true
	return u
}

func markedAt() pgtype.Timestamptz {
	return pgtype.Timestamptz{Valid: true}
}

func row(n byte, path string, missing bool) dbq.ListTrackPathsForReconcileRow {
	r := dbq.ListTrackPathsForReconcileRow{ID: testUUID(n), FilePath: path}
	if missing {
		r.MissingSince = markedAt()
	}
	return r
}

// populatedRoot returns a directory containing one file, so verifyRootsPresent
// treats it as a real, mounted library root.
func populatedRoot(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "a.mp3"), []byte("x"), 0o600); err != nil {
		t.Fatal(err)
	}
	return dir
}

func testScanner(t *testing.T, roots ...string) *Scanner {
	t.Helper()
	return &Scanner{
		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
		paths:  roots,
	}
}

func TestReconcileMissing_MarksRowsTheWalkDidNotSee(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)

	// 10 rows with 2 absent — 20%, deliberately under missingMarkMaxFraction so
	// this exercises marking rather than the cap. (An earlier version of this
	// test used 2-of-4 and was really testing the guard by accident.)
	rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
	seen := map[string]struct{}{}
	for i := 0; i < 10; i++ {
		p := fmt.Sprintf("/music/track-%02d.mp3", i)
		rows = append(rows, row(byte(i), p, false))
		if i >= 2 {
			seen[p] = struct{}{}
		}
	}
	q := &fakeReconciler{rows: rows}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if len(q.marked) != 2 {
		t.Fatalf("marked %d rows, want 2", len(q.marked))
	}
	if q.marked[0] != testUUID(0) || q.marked[1] != testUUID(1) {
		t.Errorf("marked the wrong rows: %v", q.marked)
	}
	if stats.Missing != 2 {
		t.Errorf("stats.Missing = %d, want 2", stats.Missing)
	}
	if len(q.cleared) != 0 {
		t.Errorf("cleared %d rows, want 0", len(q.cleared))
	}
}

func TestReconcileMissing_ClearsRowsWhoseFileReturned(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)
	q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
		row(1, "/music/back.mp3", true),
		row(2, "/music/still-here.mp3", false),
	}}
	seen := map[string]struct{}{
		"/music/back.mp3":       {},
		"/music/still-here.mp3": {},
	}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if len(q.cleared) != 1 || q.cleared[0] != testUUID(1) {
		t.Fatalf("cleared = %v, want just row 1", q.cleared)
	}
	if stats.Restored != 1 {
		t.Errorf("stats.Restored = %d, want 1", stats.Restored)
	}
	if len(q.marked) != 0 {
		t.Errorf("marked %d rows, want 0", len(q.marked))
	}
}

// An already-marked row must not be re-marked: the timestamp is the "how long
// has this been gone" clock that any future cleanup policy depends on.
func TestReconcileMissing_DoesNotRemarkAlreadyMissingRows(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)
	q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
		row(1, "/music/long-gone.mp3", true),
		row(2, "/music/present.mp3", false),
	}}
	seen := map[string]struct{}{"/music/present.mp3": {}}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
		t.Fatalf("reconcile: %v", err)
	}
	if len(q.marked) != 0 {
		t.Errorf("re-marked an already-missing row: %v", q.marked)
	}
	if len(q.cleared) != 0 {
		t.Errorf("cleared = %v, want none", q.cleared)
	}
}

// The guard that matters most. A half-attached mount makes the walk succeed
// while seeing only part of the library — evidence indistinguishable from a mass
// deletion, so reconcile must refuse rather than guess.
func TestReconcileMissing_RefusesWhenTooMuchWouldBeMarked(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)
	rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 100)
	seen := map[string]struct{}{}
	for i := 0; i < 100; i++ {
		p := fmt.Sprintf("/music/track-%03d.mp3", i)
		rows = append(rows, row(byte(i), p, false))
		// Only 60 of 100 present -> 40% would be marked, over the 25% cap.
		if i < 60 {
			seen[p] = struct{}{}
		}
	}
	q := &fakeReconciler{rows: rows}

	var stats Stats
	err := s.reconcileMissing(context.Background(), q, seen, &stats)
	if err == nil {
		t.Fatal("expected reconcile to refuse, got nil error")
	}
	if len(q.marked) != 0 {
		t.Errorf("marked %d rows despite refusing", len(q.marked))
	}
	if stats.Missing != 0 {
		t.Errorf("stats.Missing = %d, want 0", stats.Missing)
	}
}

// Restoring is never the dangerous direction, so it must survive the cap —
// otherwise a library that tripped the cap once could never clear its marks
// even after the volume came back.
func TestReconcileMissing_ClearsEvenWhenMarkCapTrips(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)
	rows := []dbq.ListTrackPathsForReconcileRow{row(1, "/music/back.mp3", true)}
	seen := map[string]struct{}{"/music/back.mp3": {}}
	// Add enough absent rows to blow the cap.
	for i := 2; i < 10; i++ {
		rows = append(rows, row(byte(i), fmt.Sprintf("/music/absent-%02d.mp3", i), false))
	}
	q := &fakeReconciler{rows: rows}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, seen, &stats); err == nil {
		t.Fatal("expected the mark cap to trip")
	}
	if len(q.cleared) != 1 {
		t.Errorf("cleared %d rows, want 1 — restores must not be blocked by the cap", len(q.cleared))
	}
	if stats.Restored != 1 {
		t.Errorf("stats.Restored = %d, want 1", stats.Restored)
	}
}

func TestReconcileMissing_RefusesOnEmptyWalk(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)
	q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
		row(1, "/music/a.mp3", false),
	}}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{}, &stats); err == nil {
		t.Fatal("expected refusal when the walk saw no files")
	}
	if len(q.marked) != 0 {
		t.Errorf("marked rows on an empty walk: %v", q.marked)
	}
}

// The unmounted-volume case: the configured root doesn't exist at all.
func TestReconcileMissing_RefusesWhenRootMissing(t *testing.T) {
	s := testScanner(t, filepath.Join(t.TempDir(), "not-mounted"))
	q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
		row(1, "/music/a.mp3", false),
	}}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
		t.Fatal("expected refusal when a scan root is absent")
	}
	if len(q.marked) != 0 {
		t.Errorf("marked rows with an absent root: %v", q.marked)
	}
}

// A mount point that exists but has nothing mounted on it: os.Stat succeeds on
// the bare directory, which is why emptiness is checked separately.
func TestReconcileMissing_RefusesWhenRootEmpty(t *testing.T) {
	s := testScanner(t, t.TempDir())
	q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
		row(1, "/music/a.mp3", false),
	}}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
		t.Fatal("expected refusal when a scan root is empty")
	}
}

// Several roots, one detached. Marking must not proceed on partial evidence just
// because the other roots looked fine.
func TestReconcileMissing_RefusesWhenAnyRootMissing(t *testing.T) {
	good := populatedRoot(t)
	s := testScanner(t, good, filepath.Join(t.TempDir(), "detached"))
	q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
		row(1, "/music/a.mp3", false),
	}}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
		t.Fatal("expected refusal when one of several roots is absent")
	}
}

func TestReconcileMissing_NoRowsIsNotAnError(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)
	q := &fakeReconciler{}

	var stats Stats
	if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err != nil {
		t.Fatalf("empty library should reconcile cleanly, got %v", err)
	}
}

func TestReconcileMissing_PropagatesListError(t *testing.T) {
	root := populatedRoot(t)
	s := testScanner(t, root)
	sentinel := errors.New("boom")
	q := &fakeReconciler{listErr: sentinel}

	var stats Stats
	err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats)
	if !errors.Is(err, sentinel) {
		t.Fatalf("err = %v, want it to wrap %v", err, sentinel)
	}
}

func TestVerifyRootsPresent_NoRootsConfigured(t *testing.T) {
	s := testScanner(t)
	if err := s.verifyRootsPresent(); err == nil {
		t.Fatal("expected an error with no scan roots configured")
	}
}
