package api

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5/pgtype"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
)

// newAdminQuarantineRouter builds a test chi router with the five admin
// quarantine endpoints. RequireAdmin middleware is applied; tests inject
// the user into context manually (RequireUser is bypassed).
func newAdminQuarantineRouter(h *handlers) chi.Router {
	r := chi.NewRouter()
	r.Route("/api/admin", func(admin chi.Router) {
		admin.Use(auth.RequireAdmin())
		admin.Get("/quarantine", h.handleListAdminQuarantine)
		admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
		admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
		admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
		admin.Get("/quarantine/actions", h.handleListQuarantineActions)
	})
	return r
}

// doAdminQuarantineReq fires an HTTP request against the admin quarantine
// router with the given user in context.
func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
	t.Helper()
	var buf *bytes.Buffer
	if body != nil {
		buf = bytes.NewBuffer(body)
	} else {
		buf = bytes.NewBuffer(nil)
	}
	req := httptest.NewRequest(method, path, buf)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	req = withUser(req, user)
	w := httptest.NewRecorder()
	newAdminQuarantineRouter(h).ServeHTTP(w, req)
	return w
}

// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service
// with a real clientFn pointing at the (current) saved lidarr_config. This
// mirrors testHandlersWithClientFn for the requests test file.
func installQuarantineClientFn(t *testing.T, h *handlers) {
	t.Helper()
	cfg := lidarrconfig.New(h.pool)
	clientFn := func() *lidarr.Client {
		c, err := cfg.Get(context.Background())
		if err != nil || !c.Enabled || c.BaseURL == "" {
			return nil
		}
		return lidarr.NewClient(c.BaseURL, c.APIKey)
	}
	h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn)
}

// flagDirect bypasses the HTTP handler to seed a quarantine row via the
// Service for setup. Used to construct admin queue scenarios.
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
	t.Helper()
	if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
		t.Fatalf("flagDirect: %v", err)
	}
}

// seedTrackOnDisk creates a real file under a temp dir and an artist+album+
// track row pointing at it. Used for delete-file tests that need the file
// to exist before the handler removes it.
func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, string) {
	t.Helper()
	artist := seedArtist(t, h.pool, "AdmQ Artist "+unique)
	album := seedAlbum(t, h.pool, artist.ID, "AdmQ Album "+unique, 0)
	dir := t.TempDir()
	path := filepath.Join(dir, "track-"+unique+".mp3")
	if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
		t.Fatalf("write file: %v", err)
	}
	one := int32(1)
	tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title:       "AdmQ Track " + unique,
		AlbumID:     album.ID,
		ArtistID:    artist.ID,
		TrackNumber: &one,
		DurationMs:  30000,
		FilePath:    path,
		FileSize:    5,
		FileFormat:  "mp3",
	})
	if err != nil {
		t.Fatalf("UpsertTrack: %v", err)
	}
	return tr, path
}

// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is
// required for delete-via-lidarr scenarios.
func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) {
	t.Helper()
	artist := seedArtist(t, h.pool, "DvL Artist "+unique)
	dir := t.TempDir()
	path := filepath.Join(dir, "track-"+unique+".mp3")
	if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
		t.Fatalf("write file: %v", err)
	}
	mbid := albumMBID
	album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
		Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique,
		ArtistID: artist.ID, Mbid: &mbid,
	})
	if err != nil {
		t.Fatalf("UpsertAlbum: %v", err)
	}
	one := int32(1)
	tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title:       "DvL Track " + unique,
		AlbumID:     album.ID,
		ArtistID:    artist.ID,
		TrackNumber: &one,
		DurationMs:  30000,
		FilePath:    path,
		FileSize:    5,
		FileFormat:  "mp3",
	})
	if err != nil {
		t.Fatalf("UpsertTrack: %v", err)
	}
	return tr, path
}

// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
// track with mixed reasons. Verifies one aggregated row with report_count=3,
// reason_counts populated, and a Reports list of length 3.
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	resetLidarrState(t, h)
	installQuarantineClientFn(t, h)

	alice := seedUser(t, pool, "alice", "pw", false)
	bob := seedUser(t, pool, "bob", "pw", false)
	carol := seedUser(t, pool, "carol", "pw", false)
	admin := seedUser(t, pool, "admin", "pw", true)

	track, _ := seedTrackOnDisk(t, h, "agg")
	flagDirect(t, h, alice.ID, track.ID, "bad_rip")
	flagDirect(t, h, bob.ID, track.ID, "bad_rip")
	flagDirect(t, h, carol.ID, track.ID, "wrong_tags")

	w := doAdminQuarantineReq(t, h, http.MethodGet, "/api/admin/quarantine", nil, admin)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
	}
	var rows []adminQueueRowView
	if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if len(rows) != 1 {
		t.Fatalf("len = %d, want 1 aggregated row", len(rows))
	}
	r := rows[0]
	if r.ReportCount != 3 {
		t.Errorf("report_count = %d, want 3", r.ReportCount)
	}
	if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 {
		t.Errorf("reason_counts = %+v", r.ReasonCounts)
	}
	if len(r.Reports) != 3 {
		t.Errorf("reports len = %d, want 3", len(r.Reports))
	}
}

// TestResolveQuarantine_HappyPath seeds two flags, resolves, verifies 200,
// and confirms an audit row was written.
func TestResolveQuarantine_HappyPath(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	resetLidarrState(t, h)
	installQuarantineClientFn(t, h)

	alice := seedUser(t, pool, "alice", "pw", false)
	bob := seedUser(t, pool, "bob", "pw", false)
	admin := seedUser(t, pool, "admin", "pw", true)

	track, _ := seedTrackOnDisk(t, h, "resolve")
	flagDirect(t, h, alice.ID, track.ID, "bad_rip")
	flagDirect(t, h, bob.ID, track.ID, "wrong_tags")

	w := doAdminQuarantineReq(t, h, http.MethodPost,
		"/api/admin/quarantine/"+uuidToString(track.ID)+"/resolve", nil, admin)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
	}
	var got actionResultView
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if got.AffectedUsers != 2 {
		t.Errorf("affected_users = %d, want 2", got.AffectedUsers)
	}
	if got.ActionID == "" {
		t.Error("action_id empty")
	}

	// Audit row must exist with action=resolved.
	actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
	if err != nil {
		t.Fatalf("list actions: %v", err)
	}
	if len(actions) != 1 {
		t.Fatalf("audit rows = %d, want 1", len(actions))
	}
	if actions[0].Action != dbq.LidarrQuarantineActionKindResolved {
		t.Errorf("audit action = %v, want resolved", actions[0].Action)
	}
}

// TestDeleteQuarantineFile_HappyPath seeds a flag and verifies POST
// /delete-file removes the file and the track row.
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	resetLidarrState(t, h)
	installQuarantineClientFn(t, h)

	alice := seedUser(t, pool, "alice", "pw", false)
	admin := seedUser(t, pool, "admin", "pw", true)

	track, path := seedTrackOnDisk(t, h, "delfile")
	flagDirect(t, h, alice.ID, track.ID, "bad_rip")

	w := doAdminQuarantineReq(t, h, http.MethodPost,
		"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-file", nil, admin)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
	}
	var got actionResultView
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if got.AffectedUsers != 1 {
		t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
	}

	// File and track row should be gone.
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Errorf("file still exists: %v", err)
	}
	if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
		t.Error("track row still exists")
	}
}

// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns
// a single album for the lookup and 200 for the delete. Verifies the
// audit row is written and the local track row is deleted.
func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	resetLidarrState(t, h)
	installQuarantineClientFn(t, h)

	stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
			_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`))
			return
		}
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(stub.Close)
	saveLidarrConfig(t, h, stub.URL, true)
	installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up

	alice := seedUser(t, pool, "alice", "pw", false)
	admin := seedUser(t, pool, "admin", "pw", true)

	track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl")
	flagDirect(t, h, alice.ID, track.ID, "bad_rip")

	w := doAdminQuarantineReq(t, h, http.MethodPost,
		"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
	}
	var got actionResultView
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if got.AffectedUsers != 1 {
		t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
	}
	if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 {
		t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount)
	}

	// Track row should be gone.
	if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
		t.Error("track row still exists after delete-via-lidarr")
	}
	// Audit row written with the lidarr_album_mbid.
	actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
	if err != nil {
		t.Fatalf("list actions: %v", err)
	}
	if len(actions) != 1 {
		t.Fatalf("audit rows = %d, want 1", len(actions))
	}
	if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
		t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action)
	}
	if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" {
		t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid)
	}
}

// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is
// not configured.
func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	resetLidarrState(t, h)
	installQuarantineClientFn(t, h)

	alice := seedUser(t, pool, "alice", "pw", false)
	admin := seedUser(t, pool, "admin", "pw", true)

	track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled")
	flagDirect(t, h, alice.ID, track.ID, "bad_rip")

	// Lidarr config is reset (disabled).
	w := doAdminQuarantineReq(t, h, http.MethodPost,
		"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
	if w.Code != http.StatusServiceUnavailable {
		t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
	}
	var resp map[string]string
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if resp["error"] != "lidarr_disabled" {
		t.Errorf("error = %q, want lidarr_disabled", resp["error"])
	}
}

// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing
// when the parent album has no mbid.
func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	resetLidarrState(t, h)
	installQuarantineClientFn(t, h)

	stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(stub.Close)
	saveLidarrConfig(t, h, stub.URL, true)
	installQuarantineClientFn(t, h)

	alice := seedUser(t, pool, "alice", "pw", false)
	admin := seedUser(t, pool, "admin", "pw", true)

	// Track whose album has NO mbid.
	track, _ := seedTrackOnDisk(t, h, "no-mbid")
	flagDirect(t, h, alice.ID, track.ID, "bad_rip")

	w := doAdminQuarantineReq(t, h, http.MethodPost,
		"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
	}
	var resp map[string]string
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if resp["error"] != "album_mbid_missing" {
		t.Errorf("error = %q, want album_mbid_missing", resp["error"])
	}
}

// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
// endpoints reject non-admin callers with 403.
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	resetLidarrState(t, h)
	installQuarantineClientFn(t, h)

	nonAdmin := seedUser(t, pool, "regular", "pw", false)
	fakeID := "00000000-0000-0000-0000-000000000001"

	cases := []struct {
		method string
		path   string
	}{
		{http.MethodGet, "/api/admin/quarantine"},
		{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
		{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
		{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
		{http.MethodGet, "/api/admin/quarantine/actions"},
	}
	for _, tc := range cases {
		tc := tc
		t.Run(tc.method+" "+tc.path, func(t *testing.T) {
			w := doAdminQuarantineReq(t, h, tc.method, tc.path, nil, nonAdmin)
			if w.Code != http.StatusForbidden {
				t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
			}
		})
	}
}
