package localapi

import (
	"context"
	"io"
	"log/slog"
	"net/http"
	"path/filepath"
	"sync"
	"testing"

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

// fakeIntent implements the extended IntentRecorder (SetEnrolled/SetEjected/SetDecommissioned + Get).
type fakeIntent struct {
	mu sync.Mutex
	m  map[string]storage.DriveIntent
}

func newFakeIntent() *fakeIntent { return &fakeIntent{m: map[string]storage.DriveIntent{}} }
func (f *fakeIntent) set(id string, v storage.DriveIntent) {
	f.mu.Lock()
	f.m[id] = v
	f.mu.Unlock()
}
func (f *fakeIntent) SetEnrolled(id string) error { f.set(id, storage.IntentEnrolled); return nil }
func (f *fakeIntent) SetEjected(id string) error  { f.set(id, storage.IntentEjected); return nil }
func (f *fakeIntent) SetDecommissioned(id string) error {
	f.set(id, storage.IntentDecommissioned)
	return nil
}
func (f *fakeIntent) Get(id string) storage.DriveIntent {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.m[id]
}

// decommServer wires a Server with Disks + Intent + GuestBinds + GuestAttach so both the endpoint and
// the intent-aware re-assert can be exercised. Returns the *Server (use .Handler() for HTTP tests).
func decommServer(t *testing.T, d *fakeDiskOps, sv StorageView, gl GuestLister, intent IntentRecorder, gb *GuestBindStore, ga GuestAttacher, mounts map[int]map[string]string) *Server {
	t.Helper()
	srv, err := NewServer(Options{
		ListenAddr:  "127.0.0.1:0",
		Guests:      &fakeGuestsCfg{mounts: mounts},
		Backups:     &fakeBackups{},
		Store:       &fakeStore{},
		Storage:     sv,
		Tokens:      staticTokens{"A": 8200, "B": 9300},
		Disks:       d,
		DiskGate:    &fakeGate{},
		Guests2:     gl,
		Intent:      intent,
		GuestBinds:  gb,
		GuestAttach: ga,
		HostReader:  sysOnSDA(),
		Logger:      slog.New(slog.NewTextHandler(io.Discard, nil)),
	})
	if err != nil {
		t.Fatalf("new server: %v", err)
	}
	srv.baseCtx = context.Background()
	return srv
}

// userDataAndProtected is a storage view with a user-data USB, a system dir, and a backup PBS mount.
func userDataAndProtected() fakeStorage {
	return fakeStorage{targets: []hub.StorageTarget{
		{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk", DurableID: "uuid:usb-1"},
		{Name: "local", Type: "local", MountPath: "/var/lib/vz"},
		{Name: "felhom-pbs", Type: hub.StorageTypePBS, MountPath: "/mnt/pbs"},
	}}
}

// TestDecommission_RoleGated: like eject, a system/backup mount is refused 403 with NO unmount; only a
// user-data mount decommissions. (Mirrors TestEject_RoleGated — the gate is the security control.)
func TestDecommission_RoleGated(t *testing.T) {
	for _, where := range []string{"/var/lib/vz", "/mnt/pbs", "/mnt/unknown"} {
		d := &fakeDiskOps{}
		srv := decommServer(t, d, userDataAndProtected(), fakeGuestList{}, newFakeIntent(), tempBindStore(t), &fakeGuestAttacher{}, nil)
		w := do(t, srv.Handler(), "POST", "/disks/decommission", "A", `{"where":"`+where+`"}`)
		if w.Code != http.StatusForbidden {
			t.Fatalf("decommission %s: got %d want 403 (%s)", where, w.Code, w.Body.String())
		}
		d.mu.Lock()
		if len(d.unmountCalls) != 0 {
			t.Fatalf("Unmount called on protected mount %s — role-gate bypassed", where)
		}
		d.mu.Unlock()
	}
}

// TestDecommission_Effects: a user-data decommission sets IntentDecommissioned, removes the
// GuestBindStore entry, and DETACHES the bind under the parent — but (intermediary model) does NOT
// unmount the RAW host mount, so a one-click re-enroll (H3) can re-bind it. NEVER formats.
//
// COMPANION GUARD: the pre-fix decommission unmounted the raw drive (`d.unmountCalls`), which orphaned a
// non-removable drive so re-enroll bound an empty dir. The "raw NOT unmounted" + "DetachDrive called"
// assertions below fail that impl.
func TestDecommission_Effects(t *testing.T) {
	d := &fakeDiskOps{}
	intent := newFakeIntent()
	intent.SetEnrolled("uuid:usb-1") // currently enrolled
	gb := tempBindStore(t)
	_ = gb.Record(8200, "uuid:usb-1")
	ga := &fakeGuestAttacher{}

	srv := decommServer(t, d, userDataAndProtected(), fakeGuestList{}, intent, gb, ga, nil)
	w := do(t, srv.Handler(), "POST", "/disks/decommission", "A", `{"where":"/mnt/bulk"}`)
	if w.Code != http.StatusOK {
		t.Fatalf("decommission user-data: got %d want 200 (%s)", w.Code, w.Body.String())
	}
	// 1) intent recorded as decommissioned
	if got := intent.Get("uuid:usb-1"); got != storage.IntentDecommissioned {
		t.Errorf("intent = %q, want decommissioned", got)
	}
	// 2) guest-bind record pruned
	if ids := gb.Guests()[8200]; len(ids) != 0 {
		t.Errorf("guest-bind not removed: %v", ids)
	}
	// 3) the bind under the parent is DETACHED, the RAW is NOT unmounted, and never formatted.
	if len(ga.detachDrives) != 1 || ga.detachDrives[0] != "/mnt/bulk" {
		t.Errorf("DetachDrive calls = %v, want [/mnt/bulk]", ga.detachDrives)
	}
	d.mu.Lock()
	defer d.mu.Unlock()
	if len(d.unmountCalls) != 0 {
		t.Errorf("raw drive must NOT be unmounted on decommission (re-enrollable); got %v", d.unmountCalls)
	}
	if len(d.formatCalls) != 0 {
		t.Errorf("decommission must NEVER format; formatCalls = %v", d.formatCalls)
	}
}

// TestDecommission_DeletesGuestMount is the C1-fix regression: decommission must `--delete` the guest
// mountpoint slot that binds the drive, so its now-missing source can't brick the next boot. The guest
// has two binds (bootstrap mp9 + the data drive mp1); only mp1 (the one targeting /mnt/bulk) may be
// detached.
//
// COMPANION GUARD: the pre-fix handler (the B3 bug) never called DetachBind → detachCount()==0 → this
// test FAILS on it. A trivial impl deleting the WRONG/first slot is caught by the slot==mp1 assertion.
func TestDecommission_DeletesGuestMount(t *testing.T) {
	d := &fakeDiskOps{}
	intent := newFakeIntent()
	intent.SetEnrolled("uuid:usb-1")
	ga := &fakeGuestAttacher{}
	srv := decommServer(t, d, userDataAndProtected(), fakeGuestList{}, intent, tempBindStore(t), ga, map[int]map[string]string{
		8200: {
			"mp9": "/var/lib/.../bootstrap,mp=/etc/felhom-bootstrap,ro=1",
			"mp1": "/mnt/bulk/felhom-data,mp=/mnt/bulk",
		},
	})
	w := do(t, srv.Handler(), "POST", "/disks/decommission", "A", `{"where":"/mnt/bulk"}`)
	if w.Code != http.StatusOK {
		t.Fatalf("decommission: got %d want 200 (%s)", w.Code, w.Body.String())
	}
	if ga.detachCount() != 1 {
		t.Fatalf("DetachBind called %d times, want 1 (C1 fix: the dead mp must be deleted)", ga.detachCount())
	}
	if got := ga.detaches[0]; got.vmid != 8200 || got.slot != "mp1" {
		t.Fatalf("DetachBind(vmid=%d, slot=%q), want (8200, mp1) — wrong slot deleted", got.vmid, got.slot)
	}
}

// TestReassertGuestBinds_SkipsDecommissioned is the load-bearing F9-reconnect invariant: a
// decommissioned-but-present drive still recorded in the bind store must NOT auto-rebind on agent
// restart. Companion: with intent=enrolled the SAME setup DOES rebind — proving the intent gate is
// what blocks it (the pre-fix intent-blind code would rebind both → this test FAILS on it).
func TestReassertGuestBinds_SkipsDecommissioned(t *testing.T) {
	// decommissioned → must NOT rebind even though present + recorded.
	gbD := tempBindStore(t)
	_ = gbD.Record(8200, "uuid:usb-1")
	intentD := newFakeIntent()
	intentD.SetDecommissioned("uuid:usb-1")
	gaD := &fakeGuestAttacher{}
	srvD := decommServer(t, &fakeDiskOps{}, usbPresent(), fakeGuestList{}, intentD, gbD, gaD, map[int]map[string]string{
		8200: {"mp0": "local-lvm:8,mp=/var/lib/docker"}, // bind missing → would re-add if not gated
	})
	srvD.ReassertGuestBinds(context.Background())
	if gaD.attachDriveCount() != 0 {
		t.Fatalf("decommissioned drive was re-bound (%d AttachDrive) — intent gate missing", gaD.attachDriveCount())
	}

	// companion: enrolled → DOES rebind (same present drive + missing bind).
	gbE := tempBindStore(t)
	_ = gbE.Record(8200, "uuid:usb-1")
	intentE := newFakeIntent()
	intentE.SetEnrolled("uuid:usb-1")
	gaE := &fakeGuestAttacher{}
	srvE := decommServer(t, &fakeDiskOps{}, usbPresent(), fakeGuestList{}, intentE, gbE, gaE, map[int]map[string]string{
		8200: {"mp0": "local-lvm:8,mp=/var/lib/docker"},
	})
	srvE.ReassertGuestBinds(context.Background())
	if gaE.attachDriveCount() != 1 {
		t.Fatalf("enrolled drive should rebind (got %d AttachDrive) — gate too aggressive", gaE.attachDriveCount())
	}
	if gaE.attachDrives[0] != "/mnt/felhom-usb" {
		t.Fatalf("reconcile bound the wrong path: %v", gaE.attachDrives)
	}
}

// TestReCommission_ReRecords: after a decommission prunes the bind, re-enrolling (SetEnrolled +
// Record) restores it so the re-assert rebinds again. Proves Remove doesn't break re-enroll.
func TestReCommission_ReRecords(t *testing.T) {
	gb := tempBindStore(t)
	intent := newFakeIntent()

	// decommission removed the record + set decommissioned intent.
	intent.SetDecommissioned("uuid:usb-1")
	_ = gb.Remove(8200, "uuid:usb-1") // no-op (absent) — idempotent

	// re-enroll (what handleDiskGuestAttach does): set enrolled + record.
	intent.SetEnrolled("uuid:usb-1")
	if err := gb.Record(8200, "uuid:usb-1"); err != nil {
		t.Fatal(err)
	}
	ga := &fakeGuestAttacher{}
	srv := decommServer(t, &fakeDiskOps{}, usbPresent(), fakeGuestList{}, intent, gb, ga, map[int]map[string]string{
		8200: {"mp0": "local-lvm:8,mp=/var/lib/docker"},
	})
	srv.ReassertGuestBinds(context.Background())
	if ga.attachDriveCount() != 1 {
		t.Fatalf("re-commissioned drive should rebind under the parent (got %d AttachDrive)", ga.attachDriveCount())
	}
}

// TestGuestBindStore_Remove covers idempotency (absent = no-op), present removal, empty-key drop, and
// persistence across reopen.
func TestGuestBindStore_Remove(t *testing.T) {
	path := filepath.Join(t.TempDir(), "guest-binds.json")
	gb, err := OpenGuestBindStore(path)
	if err != nil {
		t.Fatal(err)
	}
	// absent vmid + absent id → no-op, no error
	if err := gb.Remove(8200, "uuid:nope"); err != nil {
		t.Fatalf("Remove absent: %v", err)
	}
	_ = gb.Record(8200, "uuid:a")
	_ = gb.Record(8200, "uuid:b")
	// remove a present id → keeps the other
	if err := gb.Remove(8200, "uuid:a"); err != nil {
		t.Fatal(err)
	}
	if ids := gb.Guests()[8200]; len(ids) != 1 || ids[0] != "uuid:b" {
		t.Fatalf("after remove uuid:a, vmid 8200 = %v want [uuid:b]", ids)
	}
	// remove the last id → vmid key dropped
	if err := gb.Remove(8200, "uuid:b"); err != nil {
		t.Fatal(err)
	}
	if _, ok := gb.Guests()[8200]; ok {
		t.Errorf("empty vmid key should be dropped")
	}
	// persistence: reopen and confirm empty
	re, err := OpenGuestBindStore(path)
	if err != nil {
		t.Fatal(err)
	}
	if len(re.Guests()) != 0 {
		t.Errorf("store should be empty after reopen, got %v", re.Guests())
	}
}
