package api

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

	"github.com/go-chi/chi/v5"

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

// fakeLidarrUnmonitorer captures UnmonitorTrack calls for assertion and lets
// tests dictate the return error. Used to drive both the Lidarr-success and
// Lidarr-failure branches without spinning up a stub HTTP server.
type fakeLidarrUnmonitorer struct {
	calls []fakeUnmonitorCall
	err   error
}

type fakeUnmonitorCall struct {
	trackMbid string
	albumMbid string
}

func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error {
	f.calls = append(f.calls, fakeUnmonitorCall{trackMbid: trackMbid, albumMbid: albumMbid})
	return f.err
}

// installTracksLidarrStub replaces h.tracks with a fresh tracks.Service whose
// LidarrUnmonitorer is the supplied fake. Returns the fake so tests can
// assert call counts and inject error returns.
func installTracksLidarrStub(t *testing.T, h *handlers) *fakeLidarrUnmonitorer {
	t.Helper()
	fake := &fakeLidarrUnmonitorer{}
	h.tracks = tracks.NewService(h.pool, h.logger, fake, h.dataDir)
	return fake
}

// newAdminTracksRouter builds a test chi router with the admin-tracks
// endpoint. RequireAdmin middleware is applied; tests inject the user into
// context manually (RequireUser is bypassed). Mirrors the pattern from
// admin_lidarr_test.go and admin_quarantine_test.go.
func newAdminTracksRouter(h *handlers) chi.Router {
	r := chi.NewRouter()
	r.Route("/api/admin", func(admin chi.Router) {
		admin.Use(auth.RequireAdmin())
		admin.Delete("/tracks/{id}", h.handleRemoveTrack)
	})
	return r
}

// doAdminTracksReq fires an HTTP request against the admin-tracks router
// with the given user in context.
func doAdminTracksReq(t *testing.T, h *handlers, method, path string, user dbq.User) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(method, path, bytes.NewBuffer(nil))
	req = withUser(req, user)
	w := httptest.NewRecorder()
	newAdminTracksRouter(h).ServeHTTP(w, req)
	return w
}

// doAdminTracksReqNoUser fires a request directly at the handler with NO
// user injected into context. Used to exercise the defensive 401 branch
// inside the handler — real traffic hits RequireUser upstream, but the
// handler defends against routing misconfigurations and we test that path
// here. We bypass the admin router entirely since RequireAdmin would
// short-circuit with 500 internal_error on missing-user-in-context (which
// is its programmer-error contract); the handler's own 401 path is what
// we want to assert.
func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(method, path, bytes.NewBuffer(nil))
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", idParam)
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
	w := httptest.NewRecorder()
	h.handleRemoveTrack(w, req)
	return w
}

// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
// returns the track row. The mbid string is set on both track and album
// when non-empty so the unmonitor branch has the data it needs.
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
	t.Helper()
	artist := seedArtist(t, h.pool, "RemoveTest 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)
	}
	q := dbq.New(h.pool)
	var albumMbidPtr *string
	if albumMbid != "" {
		albumMbidPtr = &albumMbid
	}
	album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
		Title:     "RemoveTest Album " + unique,
		SortTitle: "RemoveTest Album " + unique,
		ArtistID:  artist.ID,
		Mbid:      albumMbidPtr,
	})
	if err != nil {
		t.Fatalf("UpsertAlbum: %v", err)
	}
	one := int32(1)
	var trackMbidPtr *string
	if trackMbid != "" {
		trackMbidPtr = &trackMbid
	}
	tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title:       "RemoveTest Track " + unique,
		AlbumID:     album.ID,
		ArtistID:    artist.ID,
		TrackNumber: &one,
		DurationMs:  30000,
		FilePath:    path,
		FileSize:    5,
		FileFormat:  "mp3",
		Mbid:        trackMbidPtr,
	})
	if err != nil {
		t.Fatalf("UpsertTrack: %v", err)
	}
	return tr, path
}

// TestHandleRemoveTrack_NonAdmin403 verifies the RequireAdmin middleware
// rejects non-admin callers. Asserts the existing middleware shape
// ({"error":"not_authorized"}) — spec §5 says `unauthorized`, but the
// shared middleware emits not_authorized today, and this handler doesn't
// own that envelope.
func TestHandleRemoveTrack_NonAdmin403(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	nonAdmin := seedUser(t, pool, "regular", "pw", false)
	fakeID := "00000000-0000-0000-0000-000000000001"

	w := doAdminTracksReq(t, h, http.MethodDelete, "/api/admin/tracks/"+fakeID, nonAdmin)
	if w.Code != http.StatusForbidden {
		t.Fatalf("status = %d, want 403; 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"] != "not_authorized" {
		t.Errorf("error = %q, want not_authorized", resp["error"])
	}
}

// TestHandleRemoveTrack_NoSession401 exercises the defensive no-user-in-
// context branch in the handler itself. Real traffic hits RequireUser
// upstream (which emits 401 with text "unauthenticated"), but the handler
// also defends against routing misconfigurations and we test that
// directly.
func TestHandleRemoveTrack_NoSession401(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)

	tr, _ := seedTrackForRemoveTest(t, h, "no-session", "", "")
	w := doAdminTracksReqNoUser(t, h, http.MethodDelete,
		"/api/admin/tracks/"+uuidToString(tr.ID), uuidToString(tr.ID))
	if w.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String())
	}
	var env errorBody
	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if env.Error.Code != "unauthenticated" {
		t.Errorf("error.code = %q, want unauthenticated", env.Error.Code)
	}
}

// TestHandleRemoveTrack_UnknownID404 verifies ErrNotFound from the service
// maps to 404 not_found in the spec envelope.
func TestHandleRemoveTrack_UnknownID404(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	admin := seedUser(t, pool, "admin", "pw", true)

	// Well-formed UUID with no matching row in the DB.
	missing := "00000000-0000-0000-0000-0000000000aa"
	w := doAdminTracksReq(t, h, http.MethodDelete, "/api/admin/tracks/"+missing, admin)
	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
	}
	var env errorBody
	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if env.Error.Code != "not_found" {
		t.Errorf("error.code = %q, want not_found", env.Error.Code)
	}
}

// TestHandleRemoveTrack_HappyPath_NoUnmonitor verifies the success envelope
// when the operator does NOT request unmonitor: 200 with deleted_track_id
// (and the optional cascade fields when they fired) but NO
// lidarr_unmonitor_failed key in the body.
func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	admin := seedUser(t, pool, "admin", "pw", true)

	// Track with no mbid; sole track on its album/artist so the cascade
	// fills both deleted_album_id and deleted_artist_id.
	tr, path := seedTrackForRemoveTest(t, h, "noumon-happy", "", "")
	w := doAdminTracksReq(t, h, http.MethodDelete,
		"/api/admin/tracks/"+uuidToString(tr.ID), admin)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
	}

	var got removeTrackResponse
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if got.DeletedTrackID != uuidToString(tr.ID) {
		t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
	}
	if got.DeletedAlbumID == nil {
		t.Error("deleted_album_id missing; expected sole-track cascade")
	}
	if got.DeletedArtistID == nil {
		t.Error("deleted_artist_id missing; expected sole-track cascade")
	}
	if got.LidarrUnmonitorFailed != nil {
		t.Errorf("lidarr_unmonitor_failed should be omitted when unmonitor not requested; got %v",
			*got.LidarrUnmonitorFailed)
	}

	// Verify the raw JSON has no `lidarr_unmonitor_failed` key — omitempty
	// on a *bool drops it, but we double-check the wire to catch
	// regressions.
	var raw map[string]any
	if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
		t.Fatalf("decode raw: %v", err)
	}
	if _, ok := raw["lidarr_unmonitor_failed"]; ok {
		t.Error("wire body contained lidarr_unmonitor_failed when unmonitor was not requested")
	}

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

// TestHandleRemoveTrack_UnmonitorFailureFlag verifies that when the
// operator sends ?unmonitor=true AND the Lidarr call fails, the response
// is still 200 (file + DB delete completed) but carries
// lidarr_unmonitor_failed: true.
func TestHandleRemoveTrack_UnmonitorFailureFlag(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	admin := seedUser(t, pool, "admin", "pw", true)

	fake := installTracksLidarrStub(t, h)
	fake.err = errors.New("lidarr exploded")

	tr, path := seedTrackForRemoveTest(t, h, "umon-fail", "track-mbid-x", "album-mbid-x")
	w := doAdminTracksReq(t, h, http.MethodDelete,
		"/api/admin/tracks/"+uuidToString(tr.ID)+"?unmonitor=true", admin)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
	}

	var got removeTrackResponse
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if got.DeletedTrackID != uuidToString(tr.ID) {
		t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
	}
	if got.LidarrUnmonitorFailed == nil || !*got.LidarrUnmonitorFailed {
		t.Errorf("lidarr_unmonitor_failed = %v, want true", got.LidarrUnmonitorFailed)
	}

	// Lidarr was actually called with the right mbids.
	if len(fake.calls) != 1 {
		t.Fatalf("UnmonitorTrack call count = %d, want 1", len(fake.calls))
	}
	if fake.calls[0].trackMbid != "track-mbid-x" || fake.calls[0].albumMbid != "album-mbid-x" {
		t.Errorf("unmonitor call = %+v, want {track-mbid-x album-mbid-x}", fake.calls[0])
	}

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