package e2e

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"testing"
	"testing/fstest"
	"time"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/core"
	"github.com/navidrome/navidrome/core/artwork"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/core/external"
	"github.com/navidrome/navidrome/core/metrics"
	"github.com/navidrome/navidrome/core/playback"
	"github.com/navidrome/navidrome/core/scrobbler"
	"github.com/navidrome/navidrome/core/storage/storagetest"
	"github.com/navidrome/navidrome/db"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/persistence"
	"github.com/navidrome/navidrome/scanner"
	"github.com/navidrome/navidrome/server/events"
	"github.com/navidrome/navidrome/server/subsonic"
	"github.com/navidrome/navidrome/server/subsonic/responses"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

func TestSubsonicE2E(t *testing.T) {
	tests.Init(t, false)
	defer db.Close(t.Context())
	log.SetLevel(log.LevelFatal)
	RegisterFailHandler(Fail)
	RunSpecs(t, "Subsonic API E2E Suite")
}

// Easy aliases for the storagetest package
type _t = map[string]any

var template = storagetest.Template
var track = storagetest.Track

// Shared test state
var (
	ctx    context.Context
	ds     *tests.MockDataStore
	router *subsonic.Router
	lib    model.Library

	// Snapshot paths for fast DB restore
	dbFilePath   string
	snapshotPath string

	// Admin user used for most tests
	adminUser = model.User{
		ID:       "admin-1",
		UserName: "admin",
		Name:     "Admin User",
		IsAdmin:  true,
	}
)

func createFS(files fstest.MapFS) storagetest.FakeFS {
	fs := storagetest.FakeFS{}
	fs.SetFiles(files)
	storagetest.Register("fake", &fs)
	return fs
}

// buildTestFS creates the full test filesystem matching the plan
func buildTestFS() storagetest.FakeFS {
	abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
	help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
	ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
	kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
	popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"})

	return createFS(fstest.MapFS{
		// Rock / The Beatles / Abbey Road
		"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together")),
		"Rock/The Beatles/Abbey Road/02 - Something.mp3":     abbeyRoad(track(2, "Something")),
		// Rock / The Beatles / Help!
		"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
		// Rock / Led Zeppelin / IV
		"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
		// Jazz / Miles Davis / Kind of Blue
		"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
		// Pop (standalone track)
		"Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")),
		// _empty folder (directory with no audio)
		"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
	})
}

// createUser creates a user in the database with the given properties, assigns them to the test
// library, and returns the fully-loaded user (with Libraries populated).
func createUser(id, username, name string, isAdmin bool) model.User {
	user := model.User{
		ID:          id,
		UserName:    username,
		Name:        name,
		IsAdmin:     isAdmin,
		NewPassword: "password",
	}
	Expect(ds.User(ctx).Put(&user)).To(Succeed())
	Expect(ds.User(ctx).SetUserLibraries(user.ID, []int{lib.ID})).To(Succeed())

	loadedUser, err := ds.User(ctx).FindByUsername(user.UserName)
	Expect(err).ToNot(HaveOccurred())
	user.Libraries = loadedUser.Libraries
	return user
}

// doReq makes a full HTTP round-trip through the router and returns the parsed Subsonic response.
func doReq(endpoint string, params ...string) *responses.Subsonic {
	return doReqWithUser(adminUser, endpoint, params...)
}

// doReqWithUser makes a full HTTP round-trip for the given user and returns the parsed Subsonic response.
func doReqWithUser(user model.User, endpoint string, params ...string) *responses.Subsonic {
	w := httptest.NewRecorder()
	r := buildReq(user, endpoint, params...)
	router.ServeHTTP(w, r)
	return parseJSONResponse(w)
}

// doRawReq returns the raw ResponseRecorder for endpoints that write binary data (stream, download, getCoverArt).
func doRawReq(endpoint string, params ...string) *httptest.ResponseRecorder {
	return doRawReqWithUser(adminUser, endpoint, params...)
}

// doRawReqWithUser returns the raw ResponseRecorder for the given user.
func doRawReqWithUser(user model.User, endpoint string, params ...string) *httptest.ResponseRecorder {
	w := httptest.NewRecorder()
	r := buildReq(user, endpoint, params...)
	router.ServeHTTP(w, r)
	return w
}

// buildReq creates a GET request with Subsonic auth params (u, p, v, c, f=json).
func buildReq(user model.User, endpoint string, params ...string) *http.Request {
	if len(params)%2 != 0 {
		panic("buildReq: odd number of parameters")
	}
	q := url.Values{}
	q.Add("u", user.UserName)
	q.Add("p", "password")
	q.Add("v", "1.16.1")
	q.Add("c", "test-client")
	q.Add("f", "json")
	for i := 0; i < len(params); i += 2 {
		q.Add(params[i], params[i+1])
	}
	return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil)
}

// parseJSONResponse parses the JSON response body into a Subsonic response struct.
func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic {
	Expect(w.Code).To(Equal(http.StatusOK))
	var wrapper responses.JsonWrapper
	Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
	return &wrapper.Subsonic
}

// --- Noop stub implementations for Router dependencies ---

// noopArtwork implements artwork.Artwork
type noopArtwork struct{}

func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) {
	return nil, time.Time{}, model.ErrNotFound
}

func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) {
	return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil
}

// noopStreamer implements core.MediaStreamer
type noopStreamer struct{}

func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) {
	return nil, model.ErrNotFound
}

func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) {
	return nil, model.ErrNotFound
}

// noopArchiver implements core.Archiver
type noopArchiver struct{}

func (n noopArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error {
	return model.ErrNotFound
}

func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error {
	return model.ErrNotFound
}

func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error {
	return model.ErrNotFound
}

func (n noopArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error {
	return model.ErrNotFound
}

// noopProvider implements external.Provider
type noopProvider struct{}

func (n noopProvider) UpdateAlbumInfo(_ context.Context, _ string) (*model.Album, error) {
	return &model.Album{}, nil
}

func (n noopProvider) UpdateArtistInfo(_ context.Context, _ string, _ int, _ bool) (*model.Artist, error) {
	return &model.Artist{}, nil
}

func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
	return nil, nil
}

func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
	return nil, nil
}

func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
	return nil, model.ErrNotFound
}

func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
	return nil, model.ErrNotFound
}

// noopPlayTracker implements scrobbler.PlayTracker
type noopPlayTracker struct{}

func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error {
	return nil
}

func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) {
	return nil, nil
}

func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error {
	return nil
}

// Compile-time interface checks
var (
	_ artwork.Artwork       = noopArtwork{}
	_ core.MediaStreamer    = noopStreamer{}
	_ core.Archiver         = noopArchiver{}
	_ external.Provider     = noopProvider{}
	_ scrobbler.PlayTracker = noopPlayTracker{}
)

var _ = BeforeSuite(func() {
	ctx = request.WithUser(GinkgoT().Context(), adminUser)
	tmpDir := GinkgoT().TempDir()
	dbFilePath = filepath.Join(tmpDir, "test-e2e.db")
	snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot")
	conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL"
	db.Db().SetMaxOpenConns(1)

	// Initial setup: schema, user, library, and full scan (runs once for the entire suite)
	conf.Server.MusicFolder = "fake:///music"
	conf.Server.DevExternalScanner = false

	db.Init(ctx)

	initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
	auth.Init(initDS)

	adminUserWithPass := adminUser
	adminUserWithPass.NewPassword = "password"
	Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed())

	lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
	Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())

	Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())

	loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
	Expect(err).ToNot(HaveOccurred())
	adminUser.Libraries = loadedUser.Libraries
	ctx = request.WithUser(GinkgoT().Context(), adminUser)

	buildTestFS()
	s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
		core.NewPlaylists(initDS), metrics.NewNoopInstance())
	_, err = s.ScanAll(ctx, true)
	Expect(err).ToNot(HaveOccurred())

	// Checkpoint WAL and snapshot the golden DB state
	_, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)")
	Expect(err).ToNot(HaveOccurred())
	data, err := os.ReadFile(dbFilePath)
	Expect(err).ToNot(HaveOccurred())
	Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed())
})

// setupTestDB restores the database from the golden snapshot and creates the
// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container.
func setupTestDB() {
	ctx = request.WithUser(GinkgoT().Context(), adminUser)

	DeferCleanup(configtest.SetupConfig())
	DeferCleanup(func() {
		// Wait for any background scan (e.g. from startScan endpoint) to finish
		// before config cleanup runs, to avoid a data race on conf.Server.
		Eventually(scanner.IsScanning).Should(BeFalse())
	})
	conf.Server.MusicFolder = "fake:///music"
	conf.Server.DevExternalScanner = false

	// Restore DB to golden state (no scan needed)
	restoreDB()

	ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
	auth.Init(ds)

	// Create the Subsonic Router with real DS + noop stubs
	s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
		core.NewPlaylists(ds), metrics.NewNoopInstance())
	router = subsonic.New(
		ds,
		noopArtwork{},
		noopStreamer{},
		noopArchiver{},
		core.NewPlayers(ds),
		noopProvider{},
		s,
		events.NoopBroker(),
		core.NewPlaylists(ds),
		noopPlayTracker{},
		core.NewShare(ds),
		playback.PlaybackServer(nil),
		metrics.NewNoopInstance(),
	)
}

// restoreDB restores all table data from the snapshot using ATTACH DATABASE.
// This is much faster than re-running the scanner for each test.
func restoreDB() {
	sqlDB := db.Db()

	_, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
	Expect(err).ToNot(HaveOccurred())

	_, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
	Expect(err).ToNot(HaveOccurred())

	rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
	Expect(err).ToNot(HaveOccurred())
	var tables []string
	for rows.Next() {
		var name string
		Expect(rows.Scan(&name)).To(Succeed())
		tables = append(tables, name)
	}
	Expect(rows.Err()).ToNot(HaveOccurred())
	rows.Close()

	for _, table := range tables {
		// Table names come from sqlite_master, not user input, so concatenation is safe here
		_, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec
		Expect(err).ToNot(HaveOccurred())
		_, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec
		Expect(err).ToNot(HaveOccurred())
	}

	_, err = sqlDB.Exec("DETACH DATABASE snapshot")
	Expect(err).ToNot(HaveOccurred())
	_, err = sqlDB.Exec("PRAGMA foreign_keys = ON")
	Expect(err).ToNot(HaveOccurred())
}
