package download

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"

	"yellowjacket/backend/database"
)

// managerFixture wires a manager over a real (in-memory) database, a
// temp staging area and a temp library root, with no network anywhere.
type managerFixture struct {
	manager *Manager
	store   *Store
	staging *Staging
	lib     *stubLibrary
	tags    *recordingTagWriter
	root    string
}

func newManagerFixture(t *testing.T) managerFixture {
	t.Helper()

	db := database.NewTestDB(t)
	seedLibrary(t, db)

	store := NewStore(db)
	staging := newTestStaging(t)
	tags := newRecordingTagWriter()
	root := t.TempDir()
	lib := &stubLibrary{path: root}

	imp := NewImporter(slogDiscard(), staging, tags, lib)

	m := NewManager(
		slogDiscard(), store, NewMemSecretStore(), staging, imp, lib,
	)
	m.SetImportOptions(ImportOptions{})

	return managerFixture{
		manager: m,
		store:   store,
		staging: staging,
		lib:     lib,
		tags:    tags,
		root:    root,
	}
}

// seedLibrary inserts the library row download_requests references.
func seedLibrary(t *testing.T, db *database.DB) {
	t.Helper()

	if _, err := db.ExecContext(
		`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
	); err != nil {
		t.Fatalf("seed library: %v", err)
	}
}

// fakeWithAlbum returns a fake provider that finds and can deliver the
// four-track reference album.
func fakeWithAlbum(id int64, name string, ext string) *FakeProvider {
	f := NewFakeProvider(id, name, Caps{CanSearch: true, CanTransport: true})

	titles := allTitles()
	c := candidateFor(name+"-cand", titles, ext, 30_000_000)
	c.ProviderID = id

	f.Candidates = []Candidate{c}

	for i, tt := range titles {
		f.Written[trackToken(i+1)+" - "+tt+ext] = []byte("audio-data")
	}

	return f
}

func TestManagerSearchRanksAcrossProviders(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	mp3 := fakeWithAlbum(1, "mp3-source", ".mp3")
	flac := fakeWithAlbum(2, "flac-source", ".flac")

	f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
	f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)

	ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
	if err != nil {
		t.Fatalf("Search: %v", err)
	}

	if len(ranked) != 2 {
		t.Fatalf("got %d candidates, want 2", len(ranked))
	}

	if ranked[0].ProviderID != 2 {
		t.Errorf(
			"winner from provider %d, want 2 (FLAC)", ranked[0].ProviderID,
		)
	}

	if mp3.SearchCalls != 1 || flac.SearchCalls != 1 {
		t.Errorf(
			"search calls: mp3=%d flac=%d, want 1 each",
			mp3.SearchCalls, flac.SearchCalls,
		)
	}
}

// One broken provider must not take the others down with it.
func TestManagerSearchToleratesProviderFailure(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	broken := NewFakeProvider(1, "broken", Caps{CanSearch: true})
	broken.SearchErr = errors.New("connection refused") //nolint:err113 // test

	working := fakeWithAlbum(2, "working", ".flac")

	f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
	f.manager.installProvider(Config{ID: 2, Priority: 50}, working)

	ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
	if err != nil {
		t.Fatalf("Search: %v", err)
	}

	if len(ranked) != 1 {
		t.Fatalf("got %d candidates, want 1 from the working provider", len(ranked))
	}
}

func TestManagerSearchNoProviders(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	_, err := f.manager.Search(context.Background(), fourTrackDownload())
	if !errors.Is(err, ErrNoProviders) {
		t.Fatalf("error = %v, want ErrNoProviders", err)
	}
}

func TestManagerSearchNoCandidates(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
	f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)

	_, err := f.manager.Search(context.Background(), fourTrackDownload())
	if !errors.Is(err, ErrNoCandidates) {
		t.Fatalf("error = %v, want ErrNoCandidates", err)
	}
}

// The whole pipeline: request → search → auto-pick → grab → verify →
// tag → import → library scan.
func TestManagerEndToEndAutoPick(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	provider := fakeWithAlbum(1, "flac-source", ".flac")
	f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)

	dl := fourTrackDownload()

	ranked, err := f.manager.Start(context.Background(), dl)
	if err != nil {
		t.Fatalf("Start: %v", err)
	}

	if !f.manager.AutoPickable(dl, ranked) {
		t.Fatalf(
			"expected a clear winner to auto-pick; best match %f quality %f",
			ranked[0].Match.Overall, ranked[0].Quality.Overall,
		)
	}

	waitForDownloadState(t, f.store, dl.ID, StateComplete)

	if provider.GrabCalls != 1 {
		t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
	}

	// Files landed in the library, laid out by the template.
	want := filepath.Join(f.root, "Radiohead", "OK Computer", "01 Airbag.flac")
	if _, err := os.Stat(want); err != nil {
		t.Errorf("expected imported file at %s: %v", want, err)
	}

	// Staging release and the rescan happen *after* the state is
	// recorded (manager.go sets StateComplete, then releases, then
	// scans), so waiting on the state is not waiting on these.  Under
	// load the worker is descheduled in between and asserting straight
	// away reads the world one step too early -- which is exactly how
	// this test failed on a busy machine while passing alone.
	waitFor(t, func() bool {
		entries, err := os.ReadDir(f.staging.Root())
		if err != nil || len(entries) != 0 {
			return false
		}

		f.lib.mu.Lock()
		defer f.lib.mu.Unlock()

		return len(f.lib.scanned) == 1
	}, "staging was never released, or the library was never rescanned")
}

// Two equally good copies are not an ambiguity — they are a spare.
//
// This asserted the opposite for as long as auto-pick required 0.08 of
// daylight over the runner-up, and that rule was wrong in exactly the
// case it fired hardest: a popular album turns up several *correct*
// copies, all matching the tracklist, differing only in format and
// seeders. There is no question there about what to fetch, only about
// which copy, and the ranking already answers that — closest to the
// preferred bitrate first. A candidate does not have to be better than
// the field, only good enough on its own terms.
func TestManagerAutoPicksAmongEquallyGoodCopies(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	a := fakeWithAlbum(1, "source-a", ".flac")
	b := fakeWithAlbum(2, "source-b", ".flac")

	f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
	f.manager.installProvider(Config{ID: 2, Priority: 50}, b)

	dl := fourTrackDownload()

	ranked, err := f.manager.Start(context.Background(), dl)
	if err != nil {
		t.Fatalf("Start: %v", err)
	}

	if veto := f.manager.AutoPickVeto(dl, ranked); veto != "" {
		t.Fatalf("two equally good copies must auto-pick, got veto: %s", veto)
	}

	waitForDownloadState(t, f.store, dl.ID, StateComplete)

	// Exactly one of them was fetched, not both.
	if grabs := a.GrabCalls + b.GrabCalls; grabs != 1 {
		t.Errorf("grabs = %d, want exactly 1", grabs)
	}
}

// The user can still pick explicitly when auto-pick is not what
// happened — a candidate the ranking did not choose is still grabbable.
func TestManagerPickIsExplicit(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	a := fakeWithAlbum(1, "source-a", ".flac")
	b := fakeWithAlbum(2, "source-b", ".flac")

	f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
	f.manager.installProvider(Config{ID: 2, Priority: 50}, b)

	// No tracklist: never auto-picks, so the result set parks for the
	// user and Pick is the only way anything is fetched.
	dl := fourTrackDownload()
	dl.Expected = nil

	ranked, err := f.manager.Start(context.Background(), dl)
	if err != nil {
		t.Fatalf("Start: %v", err)
	}

	if a.GrabCalls != 0 || b.GrabCalls != 0 {
		t.Errorf(
			"grabs happened without a pick: a=%d b=%d",
			a.GrabCalls, b.GrabCalls,
		)
	}

	stored, err := f.store.GetDownload(context.Background(), dl.ID)
	if err != nil {
		t.Fatalf("GetDownload: %v", err)
	}

	if stored.ID != dl.ID {
		t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
	}

	if err := f.manager.Pick(
		context.Background(), dl.ID, ranked[1].ID,
	); err != nil {
		t.Fatalf("Pick: %v", err)
	}

	waitForDownloadState(t, f.store, dl.ID, StateComplete)
}

func TestManagerPickUnknownCandidate(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	provider := fakeWithAlbum(1, "source", ".flac")
	f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)

	dl := fourTrackDownload()
	dl.Expected = nil // free text: never auto-picks

	if _, err := f.manager.Start(context.Background(), dl); err != nil {
		t.Fatalf("Start: %v", err)
	}

	err := f.manager.Pick(context.Background(), dl.ID, "no-such-candidate")
	if !errors.Is(err, ErrCandidateGone) {
		t.Fatalf("error = %v, want ErrCandidateGone", err)
	}
}

// A failed grab must leave staging intact for retry and must not put
// anything in the library.
func TestManagerFailedGrabLeavesLibraryClean(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	provider := fakeWithAlbum(1, "source", ".flac")
	provider.GrabErr = errors.New("peer went offline") //nolint:err113 // test

	f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)

	dl := fourTrackDownload()

	if _, err := f.manager.Start(context.Background(), dl); err != nil {
		t.Fatalf("Start: %v", err)
	}

	waitForDownloadState(t, f.store, dl.ID, StateFailed)

	entries, err := os.ReadDir(f.root)
	if err != nil {
		t.Fatalf("read library root: %v", err)
	}

	if len(entries) != 0 {
		t.Errorf("failed grab wrote %d entries into the library", len(entries))
	}

	staged, err := os.ReadDir(f.staging.Root())
	if err != nil {
		t.Fatalf("read staging root: %v", err)
	}

	if len(staged) == 0 {
		t.Error("staging released after a failure; nothing left to retry")
	}
}

// A search-only provider's candidate is fetched by whichever enabled
// transport handles its protocol.
func TestManagerPairsSearcherWithTransport(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	searcher := NewFakeProvider(1, "indexer", Caps{CanSearch: true})

	titles := allTitles()
	c := candidateFor("torrent-cand", titles, ".flac", 30_000_000)
	c.Protocol = ProtocolTorrent
	searcher.Candidates = []Candidate{c}

	transport := NewFakeProvider(2, "torrent-client", Caps{
		CanTransport: true,
		Transports:   []Protocol{ProtocolTorrent},
	})

	for i, tt := range titles {
		transport.Written[trackToken(i+1)+" - "+tt+".flac"] = []byte("audio-data")
	}

	f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
	f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)

	dl := fourTrackDownload()

	if _, err := f.manager.Start(context.Background(), dl); err != nil {
		t.Fatalf("Start: %v", err)
	}

	waitForDownloadState(t, f.store, dl.ID, StateComplete)

	if transport.GrabCalls != 1 {
		t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
	}

	if searcher.GrabCalls != 0 {
		t.Errorf("searcher should not have grabbed, got %d", searcher.GrabCalls)
	}
}

// A candidate whose protocol nothing handles must fail loudly rather
// than being silently dropped.
func TestManagerNoTransportForProtocol(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)

	searcher := NewFakeProvider(1, "indexer", Caps{CanSearch: true})

	c := candidateFor("usenet-cand", allTitles(), ".flac", 30_000_000)
	c.Protocol = ProtocolUsenet
	searcher.Candidates = []Candidate{c}

	f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)

	dl := fourTrackDownload()

	if _, err := f.manager.Start(context.Background(), dl); err != nil {
		t.Fatalf("Start: %v", err)
	}

	waitForDownloadState(t, f.store, dl.ID, StateFailed)
}

// waitForDownloadState polls until a download reaches the wanted state.
// The pipeline runs on its own goroutine, so tests observe it through
// the store rather than by reaching into the manager.
func waitForDownloadState(
	t *testing.T,
	store *Store,
	downloadID string,
	want State,
) {
	t.Helper()

	deadline := time.Now().Add(5 * time.Second)

	var last State

	for time.Now().Before(deadline) {
		state, _, err := store.GetDownloadState(context.Background(), downloadID)
		if err == nil {
			last = state
			if last == want {
				return
			}
		}

		time.Sleep(10 * time.Millisecond)
	}

	t.Fatalf("download state = %q after 5s, want %q", last, want)
}

// A delegate's files are already in the external manager's library,
// tagged by it and at paths it chose.  The pipeline must record them in
// place rather than moving them out from under a system that is still
// managing them.
func TestManagerDelegateReconcilesInPlace(t *testing.T) {
	t.Parallel()

	f := newManagerFixture(t)
	f.manager.delegatePoll = time.Millisecond

	delegate := NewFakeProvider(1, "lidarr", Caps{
		CanSearch:   true,
		CanDelegate: true,
	})

	c := candidateFor("delegate-cand", allTitles(), ".flac", 30_000_000)
	c.ProviderID = 1
	delegate.Candidates = []Candidate{c}

	external := []string{
		"/external/library/Radiohead/OK Computer/01 Airbag.flac",
		"/external/library/Radiohead/OK Computer/02 Paranoid Android.flac",
	}

	delegate.DelegateStatuses = []DelegateStatus{
		{State: StateGrabbing, Progress: 0.5},
		{State: StateComplete, Progress: 1, ImportedPaths: external},
	}

	f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)

	dl := fourTrackDownload()

	if _, err := f.manager.Start(context.Background(), dl); err != nil {
		t.Fatalf("Start: %v", err)
	}

	waitForDownloadState(t, f.store, dl.ID, StateComplete)

	if delegate.DelegateCalls != 1 {
		t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
	}

	// Nothing was tagged or written into our library root — the files
	// belong to the external manager.
	entries, err := os.ReadDir(f.root)
	if err != nil {
		t.Fatalf("read library root: %v", err)
	}

	if len(entries) != 0 {
		t.Errorf("delegate import wrote %d entries into our library", len(entries))
	}

	f.tags.mu.Lock()
	writes := len(f.tags.writes)
	f.tags.mu.Unlock()

	if writes != 0 {
		t.Errorf("tagged %d files, want 0 for a delegated import", writes)
	}

	// The external paths were recorded against the item.
	items, err := f.store.ListItemsForDownload(context.Background(), dl.ID)
	if err != nil {
		t.Fatalf("ListItemsForDownload: %v", err)
	}

	if len(items) != 1 {
		t.Fatalf("got %d items, want 1", len(items))
	}

	if len(items[0].Imported) != len(external) {
		t.Errorf(
			"recorded %v, want the external manager's paths %v",
			items[0].Imported, external,
		)
	}
}
