package main

// Integration tests for issue 0011 GAP A: `membershipd user add --store kv`
// adds users to a RUNNING cluster's replicated allowlist via the privileged
// internal connection, instead of the stop-seed-restart procedure the 0011
// deploy required. These exercise the real connectKVStore path (load the
// persisted internal identity from a file, present its nkey, open the KV store,
// write the user) against an embedded enforce node, plus the idempotency and
// error semantics the DoD calls for. Multi-node replication and node-down quorum
// are validated against the live cluster (report 0012).

import (
	"encoding/hex"
	"errors"
	"path/filepath"
	"testing"
	"time"

	cs "fn-registry/functions/cybersecurity"

	"github.com/enmanuel/unibus/pkg/busauth"
	"github.com/enmanuel/unibus/pkg/client"
	"github.com/enmanuel/unibus/pkg/embeddednats"
	"github.com/enmanuel/unibus/pkg/membership"
)

// startEnforceKVNode boots a single embedded enforce node whose authenticator
// recognizes internalPubHex as the privileged internal identity, bootstraps the
// KV control-plane store over the in-process internal connection, and publishes
// it into the holder — the exact sequence main.go performs for --store kv. It
// returns the client URL the CLI connects to.
func startEnforceKVNode(t *testing.T, internalID cs.Identity) string {
	t.Helper()
	holder := &storeHolder{}
	auth := busauth.NewNkeyAuthenticatorACLInternal(
		holder.IsAuthorized,
		busauth.PermissionsFromSubjects(holder.subjectACL),
		hex.EncodeToString(internalID.SignPub),
	)
	ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
		StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth,
	})
	if err != nil {
		t.Fatalf("start enforce node: %v", err)
	}
	t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })

	intNC, js, err := connectInternalJS(ns, internalID, true)
	if err != nil {
		t.Fatalf("bootstrap internal connection: %v", err)
	}
	t.Cleanup(intNC.Close)
	kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
	if err != nil {
		t.Fatalf("bootstrap KV store: %v", err)
	}
	holder.set(kvStore)
	return ns.ClientURL()
}

// TestUserAddStoreKV_GoldenAndIdempotent is the GAP A golden + edge-1: the CLI
// connection (real connectKVStore, loading the internal identity from a file and
// presenting its nkey) writes a user into the live KV allowlist, the user is
// authorized afterward, and re-adding the same key is an explicit ErrUserExists
// with no corruption (the unchanged row is still authorized).
func TestUserAddStoreKV_GoldenAndIdempotent(t *testing.T) {
	idFile := filepath.Join(t.TempDir(), "internal.id")
	internalID, err := client.LoadOrCreateIdentity(idFile) // persists 0600
	if err != nil {
		t.Fatalf("persist internal identity: %v", err)
	}
	url := startEnforceKVNode(t, internalID)

	// Golden: connect as the privileged internal identity (loopback, no TLS) and
	// add a new user, exactly as `user add --store kv` does.
	kv, err := connectKVStore(url, idFile, "", 1)
	if err != nil {
		t.Fatalf("connectKVStore (privileged): %v", err)
	}
	defer kv.Close()

	newUser, err := cs.GenerateIdentity()
	if err != nil {
		t.Fatalf("new user identity: %v", err)
	}
	pub := hex.EncodeToString(newUser.SignPub)
	if err := kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember); err != nil {
		t.Fatalf("add user to live KV: %v", err)
	}
	if !kv.store.IsAuthorized(pub) {
		t.Fatalf("user added to KV must be authorized")
	}

	// Edge 1: re-adding the same key is a clean, non-destructive ErrUserExists.
	err = kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember)
	if !errors.Is(err, membership.ErrUserExists) {
		t.Fatalf("re-add must return ErrUserExists (idempotent), got %v", err)
	}
	// A different handle/role with the SAME key is also rejected — the row is not
	// silently overwritten (no role flip).
	if err := kv.store.AddUser(pub, "impostor", membership.RoleAdmin); !errors.Is(err, membership.ErrUserExists) {
		t.Fatalf("re-add with a different role must NOT overwrite; want ErrUserExists, got %v", err)
	}
	u, err := kv.store.GetUser(pub)
	if err != nil {
		t.Fatalf("get user: %v", err)
	}
	if u.Handle != "gapcheck_user" || u.Role != membership.RoleMember || u.Status != membership.StatusActive {
		t.Fatalf("idempotent re-add corrupted the row: %+v", u)
	}
}

// TestUserAddStoreKV_RequiresInternalIdentity: --store kv without a usable
// internal identity file fails loudly (missing file, empty path) rather than
// silently connecting unprivileged.
func TestUserAddStoreKV_RequiresInternalIdentity(t *testing.T) {
	if _, err := connectKVStore("nats://127.0.0.1:4250", "", "", 1); err == nil {
		t.Fatalf("empty --internal-id-file must be an error")
	}
	missing := filepath.Join(t.TempDir(), "nope.id")
	if _, err := connectKVStore("nats://127.0.0.1:4250", missing, "", 1); err == nil {
		t.Fatalf("missing internal identity file must be an error")
	}
}

// TestUserAddStoreKV_UnreachableKV is the GAP A error case: pointing --store kv
// at a dead endpoint yields a clear, handled error (no crash, no silent success).
func TestUserAddStoreKV_UnreachableKV(t *testing.T) {
	idFile := filepath.Join(t.TempDir(), "internal.id")
	if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
		t.Fatalf("persist internal identity: %v", err)
	}
	// A loopback port with nothing listening: connect must fail fast and wrapped.
	_, err := connectKVStore("nats://127.0.0.1:1/", idFile, "", 1)
	if err == nil {
		t.Fatalf("connecting to a dead endpoint must error")
	}
}

// TestUserAddStoreKV_RemoteWithoutCARefused: a non-loopback target without --ca
// is refused so the allowlist write never travels in cleartext (audit 0008 N6,
// same guard as migrate-to-kv).
func TestUserAddStoreKV_RemoteWithoutCARefused(t *testing.T) {
	idFile := filepath.Join(t.TempDir(), "internal.id")
	if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
		t.Fatalf("persist internal identity: %v", err)
	}
	_, err := connectKVStore("nats://203.0.113.1:4250", idFile, "", 1)
	if err == nil {
		t.Fatalf("remote target without --ca must be refused")
	}
}
