package localapi

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
	"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)

// ---- fakes ------------------------------------------------------------------------------

// fakeGuests records every VMID it was called with, so a test can assert an op was NOT issued
// for a guest the caller is not scoped to.
type fakeGuests struct {
	mu        sync.Mutex
	snapVMIDs []int
	rbVMIDs   []int
	cfgVMIDs  []int
	mounts    map[string]string // mpN -> value, returned by GuestConfig
	failSnap  bool
}

func (f *fakeGuests) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) {
	f.mu.Lock()
	f.cfgVMIDs = append(f.cfgVMIDs, vmid)
	f.mu.Unlock()
	extra := map[string]json.RawMessage{}
	for k, v := range f.mounts {
		b, _ := json.Marshal(v)
		extra[k] = b
	}
	return proxmox.GuestConfig{Extra: extra}, nil
}

func (f *fakeGuests) Snapshot(_ context.Context, vmid int, _, _ string) (string, error) {
	f.mu.Lock()
	f.snapVMIDs = append(f.snapVMIDs, vmid)
	f.mu.Unlock()
	if f.failSnap {
		return "", fmt.Errorf("boom")
	}
	return "UPID:snap", nil
}

func (f *fakeGuests) Rollback(_ context.Context, vmid int, _ string) (string, error) {
	f.mu.Lock()
	f.rbVMIDs = append(f.rbVMIDs, vmid)
	f.mu.Unlock()
	return "UPID:rb", nil
}

func (f *fakeGuests) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) {
	return proxmox.TaskStatus{ExitStatus: "OK"}, nil
}

type fakeBackups struct {
	mu           sync.Mutex
	vmids        []int
	gate         chan struct{} // if non-nil, the backup blocks until it is closed (observe phases)
	failErr      string        // if set, the backup returns this error (drives the failed phase)
	fireSnapshot bool          // if true, invoke onSnapshot (simulates snapshot-mode detection, 8B.2)
}

func (f *fakeBackups) BackupWithSnapshotHook(_ context.Context, vmid int, onSnapshot func()) (hub.Backup, error) {
	f.mu.Lock()
	f.vmids = append(f.vmids, vmid)
	gate := f.gate
	failErr := f.failErr
	fire := f.fireSnapshot
	f.mu.Unlock()
	if fire && onSnapshot != nil {
		onSnapshot() // snapshot taken (mid-backup) → phase snapshotted
	}
	if gate != nil {
		<-gate
	}
	if failErr != "" {
		return hub.Backup{VMID: vmid}, fmt.Errorf("%s", failErr)
	}
	return hub.Backup{VMID: vmid, Success: true, Archive: "local:backup/vzdump-x", StartedAt: "2026-06-10T00:00:00Z"}, nil
}
func (f *fakeBackups) called() []int { f.mu.Lock(); defer f.mu.Unlock(); return append([]int(nil), f.vmids...) }

type fakeStore struct {
	mu      sync.Mutex
	backups []hub.Backup
	tests   []hub.RestoreTest
}

func (s *fakeStore) RecordBackup(b hub.Backup) { s.mu.Lock(); s.backups = append(s.backups, b); s.mu.Unlock() }
func (s *fakeStore) Backups(context.Context) []hub.Backup {
	s.mu.Lock()
	defer s.mu.Unlock()
	return append([]hub.Backup(nil), s.backups...)
}
func (s *fakeStore) RestoreTests(context.Context) []hub.RestoreTest {
	s.mu.Lock()
	defer s.mu.Unlock()
	return append([]hub.RestoreTest(nil), s.tests...)
}

type fakeStorage struct{ targets []hub.StorageTarget }

func (f fakeStorage) Observe(context.Context) ([]hub.StorageTarget, error) { return f.targets, nil }

// staticTokens is a fixed token→guest map for server tests (the durable store is tested
// separately). Token "A" → guest 8200, token "B" → guest 9300.
type staticTokens map[string]int

func (m staticTokens) Lookup(tok string) (int, bool) { v, ok := m[tok]; return v, ok }

// ---- harness ----------------------------------------------------------------------------

// testNow is the fixed clock the test server uses, so /backup/due cadence math is deterministic.
var testNow = time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC)

func newTestServerS(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) *Server {
	t.Helper()
	if sv == nil {
		sv = fakeStorage{}
	}
	srv, err := NewServer(Options{
		ListenAddr:    "127.0.0.1:0",
		Guests:        g,
		Backups:       b,
		Store:         st,
		Storage:       sv,
		Tokens:        staticTokens{"A": 8200, "B": 9300},
		BackupCadence: 24 * time.Hour,
		Logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
	})
	if err != nil {
		t.Fatalf("new server: %v", err)
	}
	srv.baseCtx = context.Background()
	srv.now = func() time.Time { return testNow }
	return srv
}

func newTestServer(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) http.Handler {
	t.Helper()
	return newTestServerS(t, g, b, st, sv).Handler()
}

func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
	t.Helper()
	var r *http.Request
	if body != "" {
		r = httptest.NewRequest(method, path, strings.NewReader(body))
		r.Header.Set("Content-Type", "application/json")
	} else {
		r = httptest.NewRequest(method, path, nil)
	}
	if token != "" {
		r.Header.Set("Authorization", "Bearer "+token)
	}
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	return w
}

// ---- auth -------------------------------------------------------------------------------

func TestAuth_AbsentAndWrongToken(t *testing.T) {
	g := &fakeGuests{}
	h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)

	if w := do(t, h, "GET", "/storage", "", ""); w.Code != http.StatusUnauthorized {
		t.Fatalf("absent token: got %d, want 401", w.Code)
	}
	if w := do(t, h, "GET", "/storage", "nope", ""); w.Code != http.StatusUnauthorized {
		t.Fatalf("wrong token: got %d, want 401", w.Code)
	}
	if len(g.cfgVMIDs) != 0 {
		t.Fatal("guest config was read despite failed auth")
	}
}

// ---- self-scoping: the headline security test -------------------------------------------

// A token for guest A (8200) cannot snapshot/rollback/backup guest B (9300): the agent refuses
// with 403 and the proxmox op is NEVER issued for B.
func TestSelfScoping_CrossGuestRefused(t *testing.T) {
	g := &fakeGuests{}
	b := &fakeBackups{}
	h := newTestServer(t, g, b, &fakeStore{}, nil)

	// token A targets guest B via explicit body vmid → 403, op not called.
	if w := do(t, h, "POST", "/snapshot", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden {
		t.Fatalf("cross-guest snapshot: got %d, want 403", w.Code)
	}
	if w := do(t, h, "POST", "/rollback", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden {
		t.Fatalf("cross-guest rollback: got %d, want 403", w.Code)
	}
	if w := do(t, h, "POST", "/backup", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden {
		t.Fatalf("cross-guest backup: got %d, want 403", w.Code)
	}
	// token A targets guest B via explicit query vmid → 403.
	if w := do(t, h, "GET", "/storage?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
		t.Fatalf("cross-guest storage query: got %d, want 403", w.Code)
	}

	if len(g.snapVMIDs) != 0 || len(g.rbVMIDs) != 0 {
		t.Fatalf("a guest op was issued on a cross-guest request: snaps=%v rb=%v", g.snapVMIDs, g.rbVMIDs)
	}
	// the backup goroutine must never have started for B
	time.Sleep(20 * time.Millisecond)
	if called := b.called(); len(called) != 0 {
		t.Fatalf("backup issued on a cross-guest request: %v", called)
	}
}

// Own-guest ops call the proxmox op for the CORRECT VMID (the token's guest), even when the
// caller names its own guest explicitly.
func TestSelfScoping_OwnGuestUsesTokenVMID(t *testing.T) {
	g := &fakeGuests{}
	h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)

	if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK {
		t.Fatalf("own snapshot: got %d (%s)", w.Code, w.Body.String())
	}
	if w := do(t, h, "POST", "/snapshot", "B", `{"vmid":9300,"snapname":"ok"}`); w.Code != http.StatusOK {
		t.Fatalf("own snapshot (explicit matching vmid): got %d", w.Code)
	}
	if len(g.snapVMIDs) != 2 || g.snapVMIDs[0] != 8200 || g.snapVMIDs[1] != 9300 {
		t.Fatalf("snapshot used wrong VMIDs: %v", g.snapVMIDs)
	}
}

// ---- endpoints --------------------------------------------------------------------------

func TestStorage_ReturnsOnlyThisGuestMountsWithClass(t *testing.T) {
	g := &fakeGuests{mounts: map[string]string{
		"mp0": "fastpool:8,mp=/var/lib/docker,backup=1",
		"mp1": "bulk:200,mp=/mnt/media,backup=0",
	}}
	sv := fakeStorage{targets: []hub.StorageTarget{
		{Name: "fastpool", ClassHint: "fast"},
		{Name: "bulk", ClassHint: "slow"},
	}}
	h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, sv)

	w := do(t, h, "GET", "/storage", "A", "")
	if w.Code != http.StatusOK {
		t.Fatalf("storage: got %d (%s)", w.Code, w.Body.String())
	}
	var resp struct {
		Data StorageResponse `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if resp.Data.VMID != 8200 {
		t.Fatalf("vmid: got %d want 8200", resp.Data.VMID)
	}
	byKey := map[string]MountInfo{}
	for _, m := range resp.Data.Mounts {
		byKey[m.Key] = m
	}
	if byKey["mp0"].Class != "fast" || byKey["mp0"].Storage != "fastpool" || byKey["mp0"].MountPoint != "/var/lib/docker" || !byKey["mp0"].Backup {
		t.Fatalf("mp0 wrong: %+v", byKey["mp0"])
	}
	if byKey["mp1"].Class != "slow" || byKey["mp1"].Backup {
		t.Fatalf("mp1 wrong: %+v", byKey["mp1"])
	}
	if g.cfgVMIDs[0] != 8200 {
		t.Fatalf("guest config read for wrong vmid: %v", g.cfgVMIDs)
	}
}

func TestRollback_RequiresSnapname(t *testing.T) {
	g := &fakeGuests{}
	h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)
	if w := do(t, h, "POST", "/rollback", "A", `{}`); w.Code != http.StatusBadRequest {
		t.Fatalf("missing snapname: got %d, want 400", w.Code)
	}
	if len(g.rbVMIDs) != 0 {
		t.Fatal("rollback issued without a snapname")
	}
	if w := do(t, h, "POST", "/rollback", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK {
		t.Fatalf("valid rollback: got %d", w.Code)
	}
	if len(g.rbVMIDs) != 1 || g.rbVMIDs[0] != 8200 {
		t.Fatalf("rollback VMIDs: %v", g.rbVMIDs)
	}
}

func TestSnapshot_RejectsBadName(t *testing.T) {
	g := &fakeGuests{}
	h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)
	if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"bad name/slash"}`); w.Code != http.StatusBadRequest {
		t.Fatalf("bad snapname: got %d, want 400", w.Code)
	}
	if len(g.snapVMIDs) != 0 {
		t.Fatal("snapshot issued with an invalid name")
	}
}

func TestBackup_EnqueuesForTokenGuest(t *testing.T) {
	g := &fakeGuests{}
	b := &fakeBackups{}
	st := &fakeStore{}
	h := newTestServer(t, g, b, st, nil)

	w := do(t, h, "POST", "/backup", "A", "")
	if w.Code != http.StatusAccepted {
		t.Fatalf("backup enqueue: got %d, want 202", w.Code)
	}
	// the goroutine should run and record for guest 8200
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		if c := b.called(); len(c) == 1 && c[0] == 8200 {
			break
		}
		time.Sleep(5 * time.Millisecond)
	}
	if c := b.called(); len(c) != 1 || c[0] != 8200 {
		t.Fatalf("backup not enqueued for token guest: %v", c)
	}
}

func dueOf(t *testing.T, h http.Handler) BackupDueResponse {
	t.Helper()
	w := do(t, h, "GET", "/backup/due", "A", "")
	var resp struct {
		Data BackupDueResponse `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode due: %v", err)
	}
	return resp.Data
}

// testNow is 2026-06-10T12:00:00Z, cadence 24h.
func TestBackupDue_Cadence(t *testing.T) {
	st := &fakeStore{}
	h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil)

	// no backup recorded → due
	if d := dueOf(t, h); !d.Due {
		t.Fatal("expected due=true with no backup recorded")
	}
	// a successful backup 1h ago → NOT due (within the 24h window)
	st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-10T11:00:00Z"}}
	if d := dueOf(t, h); d.Due {
		t.Fatalf("expected due=false 1h after a successful backup; reason=%q", d.Reason)
	}
	// a successful backup >24h ago → due again
	st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-08T00:00:00Z"}}
	if d := dueOf(t, h); !d.Due {
		t.Fatal("expected due=true when the newest backup is older than the cadence")
	}
	// a FAILED backup 1h ago does NOT satisfy the cadence → still due
	st.backups = []hub.Backup{{VMID: 8200, Success: false, StartedAt: "2026-06-10T11:00:00Z"}}
	if d := dueOf(t, h); !d.Due {
		t.Fatal("expected due=true: a failed backup must not count as a successful one")
	}
}

// POST /backup returns a running phase; the job transitions running→done; a successful backup then
// flips /backup/due to false (the controller won't re-quiesce in a loop).
func TestBackupStatus_RunningToDone(t *testing.T) {
	st := &fakeStore{}
	b := &fakeBackups{}
	srv := newTestServerS(t, &fakeGuests{}, b, st, nil)
	h := srv.Handler()

	// gate the backup goroutine so we can observe the running phase deterministically
	b.gate = make(chan struct{})

	w := do(t, h, "POST", "/backup", "A", "")
	if w.Code != http.StatusAccepted {
		t.Fatalf("POST /backup: got %d want 202", w.Code)
	}
	var br struct {
		Data BackupResponse `json:"data"`
	}
	_ = json.Unmarshal(w.Body.Bytes(), &br)
	if br.Data.Phase != PhaseRunning || br.Data.JobID == "" {
		t.Fatalf("expected running phase + job id, got %+v", br.Data)
	}
	if ph := statusPhase(t, h); ph != PhaseRunning {
		t.Fatalf("status while running: got %q want running", ph)
	}
	close(b.gate) // let the backup complete
	// wait for done
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		if statusPhase(t, h) == PhaseDone {
			break
		}
		time.Sleep(5 * time.Millisecond)
	}
	if ph := statusPhase(t, h); ph != PhaseDone {
		t.Fatalf("status after completion: got %q want done", ph)
	}
}

func TestBackupStatus_RunningToFailed(t *testing.T) {
	b := &fakeBackups{failErr: "vzdump exploded"}
	srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
	h := srv.Handler()

	if do(t, h, "POST", "/backup", "A", "").Code != http.StatusAccepted {
		t.Fatal("POST /backup not accepted")
	}
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		if statusPhase(t, h) == PhaseFailed {
			break
		}
		time.Sleep(5 * time.Millisecond)
	}
	if ph := statusPhase(t, h); ph != PhaseFailed {
		t.Fatalf("status after a failed backup: got %q want failed", ph)
	}
}

// A second POST /backup while one is running does NOT start a second vzdump (single-flight).
func TestBackup_SingleFlight(t *testing.T) {
	b := &fakeBackups{gate: make(chan struct{})}
	srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
	h := srv.Handler()

	do(t, h, "POST", "/backup", "A", "")
	do(t, h, "POST", "/backup", "A", "") // should be coalesced onto the running job
	close(b.gate)
	time.Sleep(30 * time.Millisecond)
	if c := b.called(); len(c) != 1 {
		t.Fatalf("expected a single vzdump for concurrent POSTs, got %d", len(c))
	}
}

func statusPhase(t *testing.T, h http.Handler) string {
	t.Helper()
	w := do(t, h, "GET", "/backup/status", "A", "")
	var resp struct {
		Data BackupStatusResponse `json:"data"`
	}
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	return resp.Data.Phase
}

func waitPhase(t *testing.T, h http.Handler, want string) {
	t.Helper()
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		if statusPhase(t, h) == want {
			return
		}
		time.Sleep(5 * time.Millisecond)
	}
	t.Fatalf("phase never reached %q (last=%q)", want, statusPhase(t, h))
}

// 8B.2: snapshot mode → the job flips to `snapshotted` mid-backup (before done), so the controller
// can resume its app early. The gated fake holds the backup open after firing onSnapshot.
func TestBackupStatus_Snapshotted_8B2(t *testing.T) {
	b := &fakeBackups{gate: make(chan struct{}), fireSnapshot: true}
	srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
	h := srv.Handler()

	if do(t, h, "POST", "/backup", "A", "").Code != http.StatusAccepted {
		t.Fatal("POST /backup not accepted")
	}
	waitPhase(t, h, PhaseSnapshotted) // onSnapshot fired → snapshotted, while the backup is still open
	close(b.gate)                     // let the backup complete
	waitPhase(t, h, PhaseDone)        // and it still proceeds to done
}

// 8B.2 fallback: stop/downgraded mode → onSnapshot never fires → phase never becomes `snapshotted`
// (the controller then resumes at done, exactly 8B).
func TestBackup_StopMode_NeverSnapshotted_8B2(t *testing.T) {
	b := &fakeBackups{gate: make(chan struct{}), fireSnapshot: false}
	srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil)
	h := srv.Handler()

	do(t, h, "POST", "/backup", "A", "")
	time.Sleep(40 * time.Millisecond) // give the goroutine time; it must NOT snapshot
	if ph := statusPhase(t, h); ph != PhaseRunning {
		t.Fatalf("stop mode: phase %q, want running (snapshotted must not be emitted)", ph)
	}
	close(b.gate)
	waitPhase(t, h, PhaseDone)
}

func TestBackupStatus_FiltersToThisGuest(t *testing.T) {
	st := &fakeStore{backups: []hub.Backup{
		{VMID: 9300, Success: true, StartedAt: "2026-06-10T10:00:00Z", Archive: "other"},
		{VMID: 8200, Success: true, StartedAt: "2026-06-10T09:00:00Z", Archive: "mine-old"},
		{VMID: 8200, Success: true, StartedAt: "2026-06-10T11:00:00Z", Archive: "mine-new"},
	}}
	h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil)
	w := do(t, h, "GET", "/backup/status", "A", "")
	if !strings.Contains(w.Body.String(), "mine-new") || strings.Contains(w.Body.String(), "other") {
		t.Fatalf("status not scoped to this guest / not latest: %s", w.Body.String())
	}
}
