package download

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
)

// yt-dlp tests drive a stub shell script rather than the real binary:
// the adapter's contract is "what argv do we build and what do we do
// with the output", and a stub tests exactly that without a network,
// a YouTube account, or a 40MB dependency.

// stubYtDlp writes an executable script that echoes the given stdout
// and returns it as a provider config binary path.
func stubYtDlp(t *testing.T, script string) string {
	t.Helper()

	if runtime.GOOS == "windows" {
		t.Skip("stub binary test uses a shell script")
	}

	path := filepath.Join(t.TempDir(), "yt-dlp")

	if err := os.WriteFile(
		path, []byte("#!/bin/sh\n"+script), 0o700,
	); err != nil {
		t.Fatalf("write stub: %v", err)
	}

	return path
}

// newStubYtDlp builds the provider over a stub binary.
func newStubYtDlp(t *testing.T, script string) *ytDlp {
	t.Helper()

	p, err := newYtDlp(
		Config{
			ID:       1,
			Kind:     KindYtDlp,
			Name:     "yt-dlp",
			Enabled:  true,
			Priority: 50,
			Settings: map[string]string{
				"binary":      stubYtDlp(t, script),
				"audioFormat": "flac",
			},
		},
		nil,
		slogDiscard(),
	)
	if err != nil {
		t.Fatalf("newYtDlp: %v", err)
	}

	y, ok := p.(*ytDlp)
	if !ok {
		t.Fatalf("provider is %T, want *ytDlp", p)
	}

	return y
}

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

	t.Run("recent version passes", func(t *testing.T) {
		t.Parallel()

		y := newStubYtDlp(t, `echo "2024.08.06"`)

		if err := y.Check(context.Background()); err != nil {
			t.Errorf("Check: %v", err)
		}
	})

	t.Run("old version is rejected", func(t *testing.T) {
		t.Parallel()

		y := newStubYtDlp(t, `echo "2021.01.01"`)

		if err := y.Check(context.Background()); !errors.Is(
			err, ErrYtDlpTooOld,
		) {
			t.Errorf("error = %v, want ErrYtDlpTooOld", err)
		}
	})

	t.Run("non-zero exit is reported", func(t *testing.T) {
		t.Parallel()

		y := newStubYtDlp(t, `exit 1`)

		if err := y.Check(context.Background()); !errors.Is(
			err, ErrYtDlpFailed,
		) {
			t.Errorf("error = %v, want ErrYtDlpFailed", err)
		}
	})
}

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

	_, err := newYtDlp(
		Config{Settings: map[string]string{
			"binary": "definitely-not-a-real-binary-xyzzy",
		}},
		nil,
		slogDiscard(),
	)

	if !errors.Is(err, ErrYtDlpMissing) {
		t.Errorf("error = %v, want ErrYtDlpMissing", err)
	}
}

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

	y := newStubYtDlp(t, `
cat <<'EOF'
{"id":"aaa","title":"Airbag","webpage_url":"https://example.com/aaa","uploader":"Radiohead","duration":284,"filesize_approx":5000000}
{"id":"bbb","title":"Paranoid Android","webpage_url":"https://example.com/bbb","uploader":"Radiohead","duration":383}
EOF
`)

	got, err := y.Search(context.Background(), Download{
		Artist: "Radiohead",
		Album:  "OK Computer",
	})
	if err != nil {
		t.Fatalf("Search: %v", err)
	}

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

	if got[0].Title != "Airbag" {
		t.Errorf("title = %q, want Airbag", got[0].Title)
	}

	if got[0].Protocol != ProtocolDirect {
		t.Errorf("protocol = %q, want direct", got[0].Protocol)
	}

	if len(got[0].Files) != 1 {
		t.Fatalf("got %d files, want 1", len(got[0].Files))
	}

	link := got[0].Payload[got[0].Files[0].Path]
	if link != "https://example.com/aaa" {
		t.Errorf("payload url = %q, want the webpage_url", link)
	}
}

// Warning text mixed into stdout must not discard valid results.
func TestYtDlpSearchSkipsUnparseableLines(t *testing.T) {
	t.Parallel()

	y := newStubYtDlp(t, `
cat <<'EOF'
WARNING: something happened
{"id":"aaa","title":"Airbag","webpage_url":"https://example.com/aaa"}
not json at all
{"id":"bbb","title":"Lucky","webpage_url":"https://example.com/bbb"}
EOF
`)

	got, err := y.Search(context.Background(), Download{Query: "radiohead"})
	if err != nil {
		t.Fatalf("Search: %v", err)
	}

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

// With a tracklist the adapter assembles an album from per-track
// searches, because a single "full album" video cannot be imported as
// separate tracks.
func TestYtDlpAssemblesAlbumFromTracklist(t *testing.T) {
	t.Parallel()

	y := newStubYtDlp(t, `
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
`)

	dl := Download{
		ID:          "dl-1",
		ReleaseMBID: "mbid-1",
		Artist:      "Radiohead",
		Album:       "OK Computer",
		Expected: []ExpectedTrack{
			{Position: 1, Title: "Airbag"},
			{Position: 2, Title: "Paranoid Android"},
			{Position: 3, Title: "Subterranean Homesick Alien"},
		},
	}

	got, err := y.Search(context.Background(), dl)
	if err != nil {
		t.Fatalf("Search: %v", err)
	}

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

	album := got[0]

	if len(album.Files) != 3 {
		t.Fatalf("got %d files, want 3", len(album.Files))
	}

	// Files are named after the expected tracks, not the video titles,
	// because the import step matches on filename.
	names := make([]string, 0, len(album.Files))
	for _, f := range album.Files {
		names = append(names, f.Path)
	}

	for _, want := range []string{
		"01 - Airbag.flac",
		"02 - Paranoid Android.flac",
		"03 - Subterranean Homesick Alien.flac",
	} {
		if !containsString(names, want) {
			t.Errorf("missing %q in %v", want, names)
		}
	}

	if album.TotalSize != 12_000_000 {
		t.Errorf("total size = %d, want 12000000", album.TotalSize)
	}
}

// A track with no search result is left out, so completeness scoring
// can speak for the gap instead of the search failing outright.
func TestYtDlpAssembleToleratesMissingTracks(t *testing.T) {
	t.Parallel()

	y := newStubYtDlp(t, `
case "$*" in
  *Airbag*) echo '{"id":"a","title":"Airbag","webpage_url":"https://example.com/a"}' ;;
  *) exit 1 ;;
esac
`)

	got, err := y.Search(context.Background(), Download{
		ID:          "req-1",
		ReleaseMBID: "mbid-1",
		Artist:      "Radiohead",
		Album:       "OK Computer",
		Expected: []ExpectedTrack{
			{Position: 1, Title: "Airbag"},
			{Position: 2, Title: "Paranoid Android"},
		},
	})
	if err != nil {
		t.Fatalf("Search: %v", err)
	}

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

	if len(got[0].Files) != 1 {
		t.Errorf("got %d files, want just the one that was found", len(got[0].Files))
	}
}

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

	// The stub writes a file at whatever --output stem it is given,
	// mimicking yt-dlp's post-extraction naming.
	y := newStubYtDlp(t, `
out=""
while [ $# -gt 0 ]; do
  case "$1" in
    --output) out="$2"; shift 2 ;;
    *) shift ;;
  esac
done
target=$(printf '%s' "$out" | sed 's/%(ext)s/flac/')
printf 'audio' > "$target"
`)

	dst := t.TempDir()

	c := Candidate{
		ID:       "ytdlp:album:req-1",
		Protocol: ProtocolDirect,
		Files: []CandidateFile{
			{Path: "01 - Airbag.flac", IsAudio: true},
		},
		Payload: map[string]string{
			"01 - Airbag.flac": "https://example.com/a",
		},
	}

	got, err := y.Grab(context.Background(), c, dst, nil)
	if err != nil {
		t.Fatalf("Grab: %v", err)
	}

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

	if _, err := os.Stat(got.Files[0]); err != nil {
		t.Errorf("downloaded file missing: %v", err)
	}

	if !strings.HasSuffix(got.Files[0], ".flac") {
		t.Errorf("file = %s, want a .flac", got.Files[0])
	}
}

// A search result is untrusted input, and yt-dlp accepts schemes that
// would read the local filesystem.
func TestYtDlpGrabRejectsNonHTTPURL(t *testing.T) {
	t.Parallel()

	y := newStubYtDlp(t, `exit 0`)

	c := Candidate{
		Files:   []CandidateFile{{Path: "x.flac", IsAudio: true}},
		Payload: map[string]string{"x.flac": "file:///etc/passwd"},
	}

	_, err := y.Grab(context.Background(), c, t.TempDir(), nil)
	if !errors.Is(err, ErrUnsafeURL) {
		t.Errorf("error = %v, want ErrUnsafeURL", err)
	}
}

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

	tests := []struct {
		url     string
		wantErr bool
	}{
		{"https://example.com/a", false},
		{"http://example.com/a", false},
		{"file:///etc/passwd", true},
		{"ftp://example.com/a", true},
		{"javascript:alert(1)", true},
		{"://nonsense", true},
	}

	for _, tt := range tests {
		t.Run(tt.url, func(t *testing.T) {
			t.Parallel()

			err := validateHTTPURL(tt.url)
			if (err != nil) != tt.wantErr {
				t.Errorf("validateHTTPURL(%q) error = %v, wantErr %v",
					tt.url, err, tt.wantErr)
			}
		})
	}
}

func containsString(haystack []string, needle string) bool {
	for _, h := range haystack {
		if h == needle {
			return true
		}
	}

	return false
}
