package localapi

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

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

// fakeNetOps records the network-mount surface calls (never touches a real host).
type fakeNetOps struct {
	ensured []storage.NetworkMountSpec
	removed []string
	list    []storage.NetworkMountStatus
	ensErr  error
}

func (f *fakeNetOps) EnsureNetworkMount(_ context.Context, s storage.NetworkMountSpec) error {
	f.ensured = append(f.ensured, s)
	return f.ensErr
}
func (f *fakeNetOps) RemoveNetworkMount(_ context.Context, n string) error {
	f.removed = append(f.removed, n)
	return nil
}
func (f *fakeNetOps) ListNetworkMounts(_ context.Context) ([]storage.NetworkMountStatus, error) {
	return f.list, nil
}

func newNetServer(t *testing.T, n NetworkStorageOps, credsDir string) *Server {
	t.Helper()
	srv, err := NewServer(Options{
		ListenAddr:  "127.0.0.1:0",
		Guests:      &fakeGuests{},
		Backups:     &fakeBackups{},
		Store:       &fakeStore{},
		Storage:     fakeStorage{},
		Tokens:      staticTokens{"A": 8200, "B": 9300},
		NetStorage:  n,
		SmbCredsDir: credsDir,
		Logger:      slog.New(slog.NewTextHandler(io.Discard, nil)),
	})
	if err != nil {
		t.Fatalf("new server: %v", err)
	}
	// Hermetic verify seams: no TCP dial, no /proc/mounts, no journalctl. Verify-specific tests
	// (netverifyjob_test.go) override the rows they exercise.
	srv.netReachable = func(storage.NetworkProtocol, string) bool { return true }
	srv.netTrigger = func(string) error { return nil }
	srv.netMounted = func(string) bool { return true }
	srv.netJournal = func(context.Context, string) (string, error) { return "", nil }
	return srv
}

func TestNetStorage_AddNFS_HappyPath(t *testing.T) {
	n := &fakeNetOps{}
	h := newNetServer(t, n, t.TempDir()).Handler()
	body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
	w := do(t, h, "POST", "/netstorage/add", "A", body)
	if w.Code != http.StatusOK {
		t.Fatalf("add NFS: got %d want 200 (%s)", w.Code, w.Body.String())
	}
	if len(n.ensured) != 1 {
		t.Fatalf("EnsureNetworkMount not called once: %v", n.ensured)
	}
	got := n.ensured[0]
	if got.Name != "media" || got.Protocol != storage.ProtocolNFS || got.Server != "10.0.0.5" || got.Export != "/srv/media" {
		t.Fatalf("spec mismatch: %+v", got)
	}
	if got.HostUID() != 101000 {
		t.Errorf("host uid = %d want 101000 (the +100000 recipe)", got.HostUID())
	}
}

func TestNetStorage_AddSMB_WritesCreds0600(t *testing.T) {
	credsDir := t.TempDir()
	n := &fakeNetOps{}
	h := newNetServer(t, n, credsDir).Handler()
	body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000,"username":"u","password":"p"}`
	w := do(t, h, "POST", "/netstorage/add", "A", body)
	if w.Code != http.StatusOK {
		t.Fatalf("add SMB: got %d want 200 (%s)", w.Code, w.Body.String())
	}
	if len(n.ensured) != 1 || n.ensured[0].CredsRef == "" {
		t.Fatalf("SMB spec must carry a creds ref: %+v", n.ensured)
	}
	credsPath := filepath.Join(credsDir, "vids.cred")
	info, err := os.Stat(credsPath)
	if err != nil {
		t.Fatalf("creds file not written: %v", err)
	}
	// 0600 is enforced on Linux (the production OS); Windows does not honor Unix perms, so the perm
	// assertion runs on the Linux build server where it matters.
	if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
		t.Errorf("creds file is group/other-readable (mode %v) — must be 0600", info.Mode().Perm())
	}
	data, _ := os.ReadFile(credsPath)
	if !strings.Contains(string(data), "username=u") || !strings.Contains(string(data), "password=p") {
		t.Errorf("creds file content wrong: %q", data)
	}
	// The secret must NOT appear in the response body.
	if strings.Contains(w.Body.String(), "\"p\"") || strings.Contains(w.Body.String(), "password") {
		t.Errorf("response leaked credentials: %s", w.Body.String())
	}
}

func TestNetStorage_AddSMB_MissingCreds_Refused(t *testing.T) {
	n := &fakeNetOps{}
	h := newNetServer(t, n, t.TempDir()).Handler()
	body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000}`
	w := do(t, h, "POST", "/netstorage/add", "A", body)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("SMB without creds: got %d want 400", w.Code)
	}
	if len(n.ensured) != 0 {
		t.Fatal("EnsureNetworkMount must not be called when creds are missing")
	}
}

// TestNetStorage_RoleGate_NonUserDataRefused: a mount root outside the user-data namespace is refused
// (403) and the mount surface is never touched — network storage is bulk-userdata ONLY.
func TestNetStorage_RoleGate_NonUserDataRefused(t *testing.T) {
	n := &fakeNetOps{}
	srv := newNetServer(t, n, t.TempDir())
	srv.netMountRoot = "/srv/system" // a non-user-data path
	h := srv.Handler()
	body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
	w := do(t, h, "POST", "/netstorage/add", "A", body)
	if w.Code != http.StatusForbidden {
		t.Fatalf("non-user-data path: got %d want 403 (%s)", w.Code, w.Body.String())
	}
	if len(n.ensured) != 0 {
		t.Fatal("a role-gated request must not reach EnsureNetworkMount")
	}
}

func TestNetStorage_List(t *testing.T) {
	n := &fakeNetOps{list: []storage.NetworkMountStatus{
		{Name: "media", Protocol: "nfs", Server: "10.0.0.5", Where: "/mnt/felhom-drives/media", Configured: true, Mounted: true, Reachable: true, Health: storage.NetHealthOK},
	}}
	h := newNetServer(t, n, t.TempDir()).Handler()
	w := do(t, h, "GET", "/netstorage", "A", "")
	if w.Code != http.StatusOK {
		t.Fatalf("list: got %d want 200 (%s)", w.Code, w.Body.String())
	}
	var resp struct {
		Data struct {
			NetworkMounts []storage.NetworkMountStatus `json:"network_mounts"`
		} `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(resp.Data.NetworkMounts) != 1 || resp.Data.NetworkMounts[0].Health != storage.NetHealthOK {
		t.Fatalf("list payload wrong: %+v", resp.Data.NetworkMounts)
	}
}

func TestNetStorage_Remove(t *testing.T) {
	credsDir := t.TempDir()
	// Pre-stage a creds file to prove removal cleans it up.
	credsPath := filepath.Join(credsDir, "media.cred")
	if err := os.WriteFile(credsPath, []byte("username=u\npassword=p\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	n := &fakeNetOps{}
	h := newNetServer(t, n, credsDir).Handler()
	w := do(t, h, "POST", "/netstorage/remove", "A", `{"name":"media"}`)
	if w.Code != http.StatusOK {
		t.Fatalf("remove: got %d want 200 (%s)", w.Code, w.Body.String())
	}
	if len(n.removed) != 1 || n.removed[0] != "media" {
		t.Fatalf("RemoveNetworkMount not called: %v", n.removed)
	}
	if _, err := os.Stat(credsPath); !os.IsNotExist(err) {
		t.Errorf("creds file should be removed, stat err = %v", err)
	}
}

func TestNetStorage_NotConfigured(t *testing.T) {
	h := newNetServer(t, nil, "").Handler() // NetStorage nil
	for _, tc := range []struct{ method, path, body string }{
		{"POST", "/netstorage/add", `{"name":"media","protocol":"nfs","server":"x","export":"/y","mapped_uid":1000,"mapped_gid":1000}`},
		{"GET", "/netstorage", ""},
		{"POST", "/netstorage/remove", `{"name":"media"}`},
	} {
		w := do(t, h, tc.method, tc.path, "A", tc.body)
		if w.Code != http.StatusServiceUnavailable {
			t.Errorf("%s %s with no NetStorage: got %d want 503", tc.method, tc.path, w.Code)
		}
	}
}

func TestNetStorage_RequiresAuth(t *testing.T) {
	h := newNetServer(t, &fakeNetOps{}, t.TempDir()).Handler()
	w := do(t, h, "GET", "/netstorage", "", "")
	if w.Code != http.StatusUnauthorized {
		t.Fatalf("no token: got %d want 401", w.Code)
	}
}
