// This file tests the `orca node` subcommand family (join/leave/list,
// capacity is covered in node_capacity_test.go). Tests execute rootCmd
// against a temp ORCA_HOME and assert stdout/stderr/exit per RESEARCH
// §1.2.
//
// daemon.go is EXCLUDED from the cli ≥70% coverage target: the daemon
// command starts a long-running mTLS server whose lifecycle is better
// covered by internal/daemon/server_test.go (already 150 LOC). The
// --pprof flag registration is verified in daemon_test.go.
package cli

import (
	"bytes"
	"context"
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"git.cloudinit.dev/coreci/orca/internal/certpaths"
	"git.cloudinit.dev/coreci/orca/internal/model"
	"git.cloudinit.dev/coreci/orca/internal/security"
	"git.cloudinit.dev/coreci/orca/internal/store"
)

func TestNodeJoinLocalText(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--name", "worker-1", "--addr", "10.0.0.5:8443"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node join: %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "Node joined") {
		t.Errorf("node join output unexpected: %s", out)
	}
	if !strings.Contains(out, "worker-1") {
		t.Errorf("node join output missing name: %s", out)
	}
}

func TestNodeJoinLocalJSON(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--name", "worker-2", "--addr", "10.0.0.6:8443", "--json"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node join --json: %v", err)
	}
	var node map[string]any
	if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &node); err != nil {
		t.Fatalf("unmarshal node json: %v\n%s", err, buf.String())
	}
	if node["name"] != "worker-2" {
		t.Errorf("node join --json name = %v, want worker-2", node["name"])
	}
	if node["address"] != "10.0.0.6:8443" {
		t.Errorf("node join --json address = %v, want 10.0.0.6:8443", node["address"])
	}
}

func TestNodeJoinMissingName(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join"})
	if err := rootCmd.Execute(); err == nil {
		t.Fatal("expected error for missing --name, got nil")
	}
}

func TestNodeJoinDefaultAddr(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--name", "defaulter", "--json"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node join: %v", err)
	}
	var node map[string]any
	if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &node); err != nil {
		t.Fatalf("unmarshal node json: %v\n%s", err, buf.String())
	}
	if node["address"] != "localhost:8443" {
		t.Errorf("node join default addr = %v, want localhost:8443", node["address"])
	}
}

func TestNodeJoinCAFingerprintMatch(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	if err := runInit(discardWriter{}); err != nil {
		t.Fatalf("init: %v", err)
	}
	fp, err := security.Fingerprint(certpaths.CACertPath())
	if err != nil {
		t.Fatalf("fingerprint: %v", err)
	}
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--name", "pinned", "--ca-fingerprint", fp, "--json"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node join with matching fingerprint: %v", err)
	}
}

func TestNodeJoinCAFingerprintMismatch(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--name", "badpin", "--ca-fingerprint", padHex(64)})
	if err := rootCmd.Execute(); err == nil {
		t.Fatal("expected error for CA fingerprint mismatch, got nil")
	}
}

func TestNodeJoinCAFingerprintNoCA(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--name", "noca", "--ca-fingerprint", padHex(64)})
	if err := rootCmd.Execute(); err == nil {
		t.Fatal("expected error for missing CA with --ca-fingerprint, got nil")
	}
}

func TestNodeJoinProxmoxMissingHost(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--ssh-key", "/tmp/nonexistent-key"})
	if err := rootCmd.Execute(); err == nil {
		t.Fatal("expected error for proxmox without --host, got nil")
	}
}

func TestNodeJoinProxmoxMissingSSHKey(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	// No --ssh-key and no default orca key -> error (R-021).
	rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--host", "10.0.0.99"})
	if err := rootCmd.Execute(); err == nil {
		t.Fatal("expected error for proxmox without ssh-key, got nil")
	}
}

func TestNodeListEmpty(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "list"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node list: %v", err)
	}
}

func TestNodeListAfterJoin(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	rootCmd.SetArgs([]string{"node", "join", "--name", "lister", "--addr", "10.0.0.7:8443"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node join: %v", err)
	}
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "list"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node list: %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "lister") {
		t.Errorf("node list missing joined node: %s", out)
	}
}

func TestNodeListJSON(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	rootCmd.SetArgs([]string{"node", "join", "--name", "jsonlister", "--addr", "10.0.0.8:8443"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node join: %v", err)
	}
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "list", "--json"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node list --json: %v", err)
	}
	var nodes []map[string]any
	if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &nodes); err != nil {
		t.Fatalf("unmarshal node list json: %v\n%s", err, buf.String())
	}
	found := false
	for _, n := range nodes {
		if n["name"] == "jsonlister" {
			found = true
		}
	}
	if !found {
		t.Errorf("node list --json missing jsonlister: %s", buf.String())
	}
}

func TestNodeLeave(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	nodeID := seedNode(t, "leaver", "10.0.0.9:8443")
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "leave", nodeID})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node leave: %v", err)
	}
	if !strings.Contains(buf.String(), "Node left") {
		t.Errorf("node leave output unexpected: %s", buf.String())
	}
}

func TestNodeLeaveJSON(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	nodeID := seedNode(t, "jsonleaver", "10.0.0.10:8443")
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "leave", nodeID, "--json"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node leave --json: %v", err)
	}
	var result map[string]any
	if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
		t.Fatalf("unmarshal node leave json: %v\n%s", err, buf.String())
	}
	if result["state"] != "left" {
		t.Errorf("node leave --json state = %v, want left", result["state"])
	}
}

func TestNodeLeaveMissingID(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "leave"})
	if err := rootCmd.Execute(); err == nil {
		t.Fatal("expected error for node leave without id, got nil")
	}
}

func seedNode(t *testing.T, name, addr string) string {
	t.Helper()
	db, err := store.Open(certpaths.DBPath())
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	repo := store.NewNodeRepo(db)
	ctx := context.Background()
	n := &model.Node{
		ID:       "node-" + name,
		Name:     name,
		Address:  addr,
		State:    model.NodeStateReady,
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("insert node: %v", err)
	}
	return n.ID
}

func padHex(n int) string {
	b := make([]byte, n)
	for i := range b {
		b[i] = 'a'
	}
	return string(b)
}

// TestNodeKeyReset removes the target node's known_hosts lines, leaves
// other hosts' lines intact, and inserts an audit row (T02.8, REQ-059).
func TestNodeKeyReset(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()

	// Seed a proxmox node whose Name is the host address (matches the
	// key-reset RunE, which uses node.Name as the known_hosts match key).
	seedProxmoxNode(t, "10.0.0.1", "10.0.0.1:8443")

	// Pre-populate known_hosts: 2 lines for the target + 1 for another host.
	knownHosts := certpaths.KnownHostsPath()
	if err := os.MkdirAll(filepath.Dir(knownHosts), 0o755); err != nil {
		t.Fatalf("mkdir known_hosts dir: %v", err)
	}
	original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" +
		"10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" +
		"[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n")
	if err := os.WriteFile(knownHosts, original, 0o600); err != nil {
		t.Fatalf("write known_hosts: %v", err)
	}

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "key-reset", "10.0.0.1"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("node key-reset: %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "Host key reset for 10.0.0.1") {
		t.Errorf("output missing reset confirmation: %s", out)
	}

	// known_hosts: target's 2 lines removed, other host's line intact.
	data, err := os.ReadFile(knownHosts)
	if err != nil {
		t.Fatalf("read known_hosts: %v", err)
	}
	result := string(data)
	if strings.Contains(result, "AAAAKEY1") {
		t.Errorf("target key line 1 not removed: %s", result)
	}
	if strings.Contains(result, "AAAAKEY1ALT") {
		t.Errorf("target key line 2 not removed: %s", result)
	}
	if !strings.Contains(result, "AAAAKEY2") {
		t.Errorf("other host's line was removed (should be intact): %s", result)
	}

	// Audit row inserted with action=node.key_reset.
	db, err := store.Open(certpaths.DBPath())
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	entries, err := store.NewAuditRepo(db).List(context.Background(), 50)
	if err != nil {
		t.Fatalf("list audit: %v", err)
	}
	found := false
	for _, e := range entries {
		if e.Action == "node.key_reset" && strings.Contains(e.Resource, "10.0.0.1") {
			found = true
			break
		}
	}
	if !found {
		t.Errorf("audit row for node.key_reset not inserted: %+v", entries)
	}
}

// TestNodeKeyReset_NodeNotFound verifies key-reset errors when the
// node is not in the registry (T02.8).
func TestNodeKeyReset_NodeNotFound(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "key-reset", "no.such.host"})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("expected error for unknown node, got nil")
	}
	if !strings.Contains(err.Error(), "not found") {
		t.Errorf("error should mention not found, got: %v", err)
	}
}

func seedProxmoxNode(t *testing.T, name, addr string) string {
	t.Helper()
	db, err := store.Open(certpaths.DBPath())
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	repo := store.NewNodeRepo(db)
	ctx := context.Background()
	n := &model.Node{
		ID:       "node-" + name,
		Name:     name,
		Address:  addr,
		State:    model.NodeStateReady,
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
		Kind:     string(model.NodeKindProxmox),
		OS:       "pve",
	}
	if err := repo.Insert(ctx, n); err != nil {
		t.Fatalf("insert proxmox node: %v", err)
	}
	return n.ID
}

// TestNodeJoinHostKeyFingerprintRequiresProxmox verifies T02.11:
// `orca node join --type linux --host-key-fingerprint SHA256:...`
// fails with a clear error from the D-044 RunE check. Exercises the
// cobra Execute() error path end-to-end.
func TestNodeJoinHostKeyFingerprintRequiresProxmox(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{
		"node", "join",
		"--type", "linux",
		"--name", "linux-node",
		"--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
	})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("expected error for --host-key-fingerprint without --type proxmox, got nil")
	}
	if !strings.Contains(err.Error(), "--host-key-fingerprint requires --type proxmox") {
		t.Errorf("error should mention the --host-key-fingerprint/--type proxmox requirement, got: %v", err)
	}
}

// TestNodeJoinHostKeyFingerprintProxmoxAccepted verifies that
// --host-key-fingerprint IS accepted for --type proxmox (the RunE check
// does not reject a proxmox-type join that pins the host key). This is
// the negative-space companion to TestNodeJoinHostKeyFingerprintRequiresProxmox
// (T02.11): the validation must only reject non-proxmox types.
//
// We can't run the full bootstrap without a real SSH server, so we
// assert that the RunE check passes (no "requires --type proxmox"
// error) and the failure — if any — comes from a later stage (missing
// --host / ssh-key), not the D-044 guard.
func TestNodeJoinHostKeyFingerprintProxmoxAccepted(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{
		"node", "join",
		"--type", "proxmox",
		"--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
	})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("expected a later-stage error (missing --host), got nil")
	}
	if strings.Contains(err.Error(), "requires --type proxmox") {
		t.Errorf("D-044 guard wrongly rejected proxmox type: %v", err)
	}
}

// --- REQ-146 / R-021 password removal regression test ---

// TestNodeJoinProxmoxPasswordRejected verifies the --password flag is
// no longer accepted (R-021: no passwords). The flag is removed; the
// CLI should reject it as an unknown flag.
func TestNodeJoinProxmoxPasswordRejected(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--host", "10.0.0.99", "--password", "secret"})
	if err := rootCmd.Execute(); err == nil {
		t.Fatal("expected error for --password (R-021: no passwords), got nil")
	}
}
