package localapi

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"

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

// wrapperRunner captures the wrapper vector without ever running sudo. The wrapper IS the security
// boundary, so tests substitute it rather than bypassing it — what is asserted here is the ORDER and
// the ARGUMENTS the agent sends, which is the agent's half of the contract.
type wrapperRunner struct {
	calls  [][]string
	failOn string // verb to fail, "" = all succeed
}

func (r *wrapperRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
	r.calls = append(r.calls, append([]string{name}, args...))
	if len(args) > 0 && args[0] == r.failOn {
		return nil, []byte("felhom-backup-target-apply: REFUSED: synthetic " + r.failOn + " failure\n"),
			io.ErrUnexpectedEOF
	}
	return nil, nil, nil
}

func (r *wrapperRunner) verbs() []string {
	var out []string
	for _, c := range r.calls {
		if len(c) > 1 {
			out = append(out, c[1])
		}
	}
	return out
}

// moveServer builds a server with /mnt/data mounted on its own device and / on another, plus a
// throwaway agent.json the move can rewrite.
func moveServer(t *testing.T, run *wrapperRunner) (http.Handler, string) {
	t.Helper()
	dir := t.TempDir()
	cfgPath := filepath.Join(dir, "agent.json")
	// An UNKNOWN key is deliberately present: the rewrite must preserve it verbatim.
	seed := `{"backup":{"local_backup_target":"local","local_backup_retention":3},"some_future_key":{"keep":"me"}}`
	if err := os.WriteFile(cfgPath, []byte(seed), 0o600); err != nil {
		t.Fatalf("seed config: %v", err)
	}
	hr := fakeHostReader{mounts: []storage.Mount{
		{Device: "/dev/sda1", MountPoint: "/"},
		{Device: "/dev/sdb1", MountPoint: "/mnt/data"},
	}}
	srv, err := NewServer(Options{
		ListenAddr: "127.0.0.1:0",
		Guests:     &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{}, Storage: fakeStorage{},
		Tokens: staticTokens{"A": 8200}, HostReader: hr,
		Privileged: run, ConfigPath: cfgPath, StateDir: dir,
		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
	})
	if err != nil {
		t.Fatalf("new server: %v", err)
	}
	srv.baseCtx = context.Background()
	return srv.Handler(), cfgPath
}

// THE ORDER IS THE CONTRACT: create → grant → config. Reversed, a config pointing at an ungranted
// storage makes every backup 403 on first run, which is precisely what E-1 hit (finding F-3).
func TestBackupTargetMoveOrdersCreateThenGrantThenConfig(t *testing.T) {
	run := &wrapperRunner{}
	h, cfgPath := moveServer(t, run)

	rr := do(t, h, http.MethodPost, "/backup/target", "A", `{"vmid":8200,"where":"/mnt/data"}`)
	if rr.Code != http.StatusOK {
		t.Fatalf("move = %d, body %s", rr.Code, rr.Body.String())
	}
	got := strings.Join(run.verbs(), ",")
	if got != "create,grant" {
		t.Fatalf("wrapper verbs = %q, want create,grant (in that order)", got)
	}
	// The config must have been written only AFTER both wrapper calls succeeded.
	raw, _ := os.ReadFile(cfgPath)
	var doc map[string]json.RawMessage
	if err := json.Unmarshal(raw, &doc); err != nil {
		t.Fatalf("config unreadable after move: %v", err)
	}
	var bk map[string]any
	_ = json.Unmarshal(doc["backup"], &bk)
	if bk["local_backup_target"] != "felhom-backup" {
		t.Errorf("local_backup_target = %v, want felhom-backup", bk["local_backup_target"])
	}
	// Unknown keys preserved verbatim — the property that made E-1's hand edit safe.
	if _, ok := doc["some_future_key"]; !ok {
		t.Error("the rewrite DROPPED an unknown top-level key — a typed round-trip would do this " +
			"and silently discard config this build does not know about")
	}
	// Sibling keys inside `backup` survive too.
	if bk["local_backup_retention"] == nil {
		t.Error("the rewrite dropped local_backup_retention from the backup section")
	}
}

// A FAILED GRANT MUST NOT LEAVE THE CONFIG POINTING AT THE NEW STORAGE. That state is exactly E-1's
// 403-on-every-backup: the tier looks configured and cannot write.
func TestBackupTargetMoveDoesNotRepointWhenTheGrantFails(t *testing.T) {
	run := &wrapperRunner{failOn: "grant"}
	h, cfgPath := moveServer(t, run)

	rr := do(t, h, http.MethodPost, "/backup/target", "A", `{"vmid":8200,"where":"/mnt/data"}`)
	if rr.Code == http.StatusOK {
		t.Fatalf("move SUCCEEDED despite a failed grant (%d)", rr.Code)
	}
	if !strings.Contains(rr.Body.String(), "403") {
		t.Errorf("the error should name the consequence (backups would 403); got %s", rr.Body.String())
	}
	raw, _ := os.ReadFile(cfgPath)
	if strings.Contains(string(raw), "felhom-backup") {
		t.Fatal("the config was repointed at a storage the agent cannot write to — every backup " +
			"would 403 while the tier reported as configured")
	}
}

// It must NOT restart the agent itself. Restarting with a backup in flight cancels the wait and
// records a spurious tier failure for a backup that actually succeeded — E-1 did exactly that to a
// felhom-pbs run. Only the caller can re-check in-flight work immediately before restarting.
func TestBackupTargetMoveReportsRestartRequiredRatherThanRestarting(t *testing.T) {
	run := &wrapperRunner{}
	h, _ := moveServer(t, run)
	rr := do(t, h, http.MethodPost, "/backup/target", "A", `{"vmid":8200,"where":"/mnt/data"}`)
	if !strings.Contains(rr.Body.String(), `"restart_required":true`) {
		t.Errorf("response must tell the caller a restart is required; got %s", rr.Body.String())
	}
	for _, c := range run.calls {
		joined := strings.Join(c, " ")
		if strings.Contains(joined, "systemctl") || strings.Contains(joined, "restart") {
			t.Fatalf("the handler restarted the agent itself: %q — the caller must do it behind its "+
				"own in-flight check", joined)
		}
	}
}

// A path that is not a mountpoint is refused BEFORE sudo is reached (F-1): a subdirectory target
// reports disconnected forever, and an unmounted path silently retargets onto the system drive.
func TestBackupTargetMoveRefusesANonMountpoint(t *testing.T) {
	run := &wrapperRunner{}
	h, _ := moveServer(t, run)
	rr := do(t, h, http.MethodPost, "/backup/target", "A", `{"vmid":8200,"where":"/mnt/data/sub"}`)
	if rr.Code == http.StatusOK {
		t.Fatal("a non-mountpoint was accepted as the backup target")
	}
	if len(run.calls) != 0 {
		t.Errorf("a refused request still reached the privileged wrapper: %v", run.calls)
	}
}

// The system disk is refused: a target there protects against corruption only, never drive loss —
// which is the entire point of the move.
func TestBackupTargetMoveRefusesTheSystemDisk(t *testing.T) {
	run := &wrapperRunner{}
	h, _ := moveServer(t, run)
	rr := do(t, h, http.MethodPost, "/backup/target", "A", `{"vmid":8200,"where":"/"}`)
	if rr.Code == http.StatusOK {
		t.Fatal("the system disk was accepted as the backup target")
	}
	if len(run.calls) != 0 {
		t.Errorf("a refused request still reached the privileged wrapper: %v", run.calls)
	}
}
