package doctor

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"net"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"golang.org/x/crypto/ssh"

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

// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir.
// With the P02 real checks (no stubs): cert checks FAIL (no CA),
// db check PASS (store.Open runs migrations), network check WARN
// (no peers).
func TestRunAllChecksWithNoCA(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	rep := Run(context.Background())
	if len(rep.Checks) == 0 {
		t.Fatal("expected checks, got 0")
	}

	byName := make(map[string]CheckResult, len(rep.Checks))
	for _, c := range rep.Checks {
		byName[c.Name] = c
	}

	// Cert checks: no CA → FAIL.
	for _, name := range []string{"cert.ca", "cert.server", "cert.expiry", "cert.fingerprint"} {
		c, ok := byName[name]
		if !ok {
			t.Errorf("missing check %s", name)
			continue
		}
		if c.Result != ResultFail {
			t.Errorf("%s: got %s, want FAIL — %s", name, c.Result, c.Message)
		}
	}

	// DB check: store.Open runs migrations → PASS.
	if c, ok := byName["db"]; ok {
		if c.Result != ResultPass {
			t.Errorf("db: got %s, want PASS — %s", c.Result, c.Message)
		}
	} else {
		t.Error("missing check db")
	}

	// Network check: no CA → FAIL (can't build mTLS client without CA).
	if c, ok := byName["network"]; ok {
		if c.Result != ResultFail {
			t.Errorf("network: got %s, want FAIL (no CA cert) — %s", c.Result, c.Message)
		}
	} else {
		t.Error("missing check network")
	}
}

// TestRunWithCAAndServerCert covers the happy path: CA + server cert
// installed → all cert checks PASS, db PASS, network WARN (no peers).
func TestRunWithCAAndServerCert(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	if _, err := security.CAInit(dir, "test-ca"); err != nil {
		t.Fatalf("CAInit: %v", err)
	}
	ca, err := security.LoadCA(dir)
	if err != nil {
		t.Fatalf("LoadCA: %v", err)
	}
	keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
	if err != nil {
		t.Fatalf("GenerateCSR: %v", err)
	}
	certPEM, err := ca.SignCSR(csrPEM)
	if err != nil {
		t.Fatalf("SignCSR: %v", err)
	}
	if err := security.WriteCert(dir+"/server.crt", certPEM); err != nil {
		t.Fatalf("WriteCert: %v", err)
	}
	if err := security.WriteKey(dir+"/server.key", keyPEM); err != nil {
		t.Fatalf("WriteKey: %v", err)
	}

	rep := Run(context.Background())
	byName := make(map[string]CheckResult, len(rep.Checks))
	for _, c := range rep.Checks {
		byName[c.Name] = c
	}

	for _, name := range []string{"cert.ca", "cert.server", "cert.expiry", "cert.fingerprint", "db"} {
		c, ok := byName[name]
		if !ok {
			t.Errorf("missing check %s", name)
			continue
		}
		if c.Result != ResultPass {
			t.Errorf("%s: got %s, want PASS — %s", name, c.Result, c.Message)
		}
	}

	if c, ok := byName["network"]; ok {
		if c.Result != ResultWarn {
			t.Errorf("network: got %s, want WARN (no peers) — %s", c.Result, c.Message)
		}
	}
}

// TestDBCheck_IntegrityOK verifies the DB check passes on a fresh
// database with migrations applied.
func TestDBCheck_IntegrityOK(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	db, err := store.Open(filepath.Join(dir, "orca.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()

	c := DB()
	r, msg := c.Run(context.Background())
	if r != ResultPass {
		t.Errorf("DB check: got %s, want PASS — %s", r, msg)
	}
	if !strings.Contains(msg, "migrations up to") {
		t.Errorf("DB check message should contain migration version, got: %s", msg)
	}
}

// TestNetworkCheck_NoPeers verifies the network check returns WARN
// when no peers are registered.
func TestNetworkCheck_NoPeers(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	// Create a CA + server cert so the network check can build a client.
	if _, err := security.CAInit(dir, "test-ca"); err != nil {
		t.Fatalf("CAInit: %v", err)
	}
	ca, _ := security.LoadCA(dir)
	keyPEM, csrPEM, _ := security.GenerateCSR("test-server", []string{"localhost"})
	certPEM, _ := ca.SignCSR(csrPEM)
	_ = security.WriteCert(dir+"/server.crt", certPEM)
	_ = security.WriteKey(dir+"/server.key", keyPEM)

	c := Network()
	r, msg := c.Run(context.Background())
	if r != ResultWarn {
		t.Errorf("Network check: got %s, want WARN — %s", r, msg)
	}
	if !strings.Contains(msg, "no peers") {
		t.Errorf("Network check message should mention no peers, got: %s", msg)
	}
}

// TestNetworkCheck_PeerUnreachable verifies the network check returns
// FAIL when a registered peer is not reachable.
func TestNetworkCheck_PeerUnreachable(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	// Create a CA + server cert.
	if _, err := security.CAInit(dir, "test-ca"); err != nil {
		t.Fatalf("CAInit: %v", err)
	}
	ca, _ := security.LoadCA(dir)
	keyPEM, csrPEM, _ := security.GenerateCSR("test-server", []string{"localhost"})
	certPEM, _ := ca.SignCSR(csrPEM)
	_ = security.WriteCert(dir+"/server.crt", certPEM)
	_ = security.WriteKey(dir+"/server.key", keyPEM)

	// Insert a peer node with an unreachable address.
	db, err := store.Open(filepath.Join(dir, "orca.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	repo := store.NewNodeRepo(db)
	_ = repo.Insert(context.Background(), &model.Node{
		ID: "dead-peer", Name: "dead", Address: "127.0.0.1:1",
		State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
	})

	c := Network()
	r, msg := c.Run(context.Background())
	if r != ResultFail {
		t.Errorf("Network check: got %s, want FAIL — %s", r, msg)
	}
	if !strings.Contains(msg, "dead") {
		t.Errorf("Network check message should mention the dead peer, got: %s", msg)
	}
}

// TestNetworkCheck_NoCert verifies the network check returns FAIL
// when no CA cert is installed.
func TestNetworkCheck_NoCert(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	c := Network()
	r, msg := c.Run(context.Background())
	if r != ResultFail {
		t.Errorf("Network check: got %s, want FAIL — %s", r, msg)
	}
	if !strings.Contains(msg, "CA cert missing") {
		t.Errorf("Network check message should mention missing CA, got: %s", msg)
	}
}

// TestRenderReport verifies the report output format.
func TestRenderReport(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	rep := Run(context.Background())
	out := rep.Print()
	if !strings.Contains(out, "PASS") {
		t.Errorf("expected PASS in output, got: %s", out)
	}
	if !strings.Contains(out, "WARN") {
		t.Errorf("expected WARN in output, got: %s", out)
	}
	if !strings.Contains(out, "FAIL") {
		t.Errorf("expected FAIL in output, got: %s", out)
	}
}

// TestOSCheck_MissingLocalhostNode verifies the OS check returns FAIL
// when no localhost node is registered.
func TestOSCheck_MissingLocalhostNode(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	// Open the DB to apply migrations but insert no nodes.
	db, err := store.Open(filepath.Join(dir, "orca.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	db.Close()

	c := OS()
	r, msg := c.Run(context.Background())
	if r != ResultFail {
		t.Errorf("OS check: got %s, want FAIL — %s", r, msg)
	}
	if !strings.Contains(msg, "no localhost node") {
		t.Errorf("OS check message should mention missing localhost node, got: %s", msg)
	}
}

// TestOSCheck_Match verifies the OS check returns PASS when the stored
// localhost node's os matches the detected OS.
func TestOSCheck_Match(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	db, err := store.Open(filepath.Join(dir, "orca.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	repo := store.NewNodeRepo(db)

	// Insert a localhost node with the currently-detected OS.
	detected := osdetect.Detect()
	if err := repo.Insert(context.Background(), &model.Node{
		ID: "os-match-1", Name: "localhost", Address: "localhost:8443",
		State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
		Kind: "localhost", OS: detected,
	}); err != nil {
		t.Fatalf("insert: %v", err)
	}

	c := OS()
	r, msg := c.Run(context.Background())
	if r != ResultPass {
		t.Errorf("OS check: got %s, want PASS — %s", r, msg)
	}
	if !strings.Contains(msg, detected) {
		t.Errorf("OS check message should contain %s, got: %s", detected, msg)
	}
}

// TestOSCheck_Drift verifies the OS check returns WARN when the stored
// os differs from the detected os.
func TestOSCheck_Drift(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	db, err := store.Open(filepath.Join(dir, "orca.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	repo := store.NewNodeRepo(db)

	// Insert a localhost node with a deliberately wrong OS.
	if err := repo.Insert(context.Background(), &model.Node{
		ID: "os-drift-1", Name: "localhost", Address: "localhost:8443",
		State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
		Kind: "localhost", OS: "debian",
	}); err != nil {
		t.Fatalf("insert: %v", err)
	}

	c := OS()
	r, msg := c.Run(context.Background())
	if r != ResultWarn {
		t.Errorf("OS check: got %s, want WARN — %s", r, msg)
	}
	if !strings.Contains(msg, "drift") {
		t.Errorf("OS check message should mention drift, got: %s", msg)
	}
}

// TestProxmoxCheck_NoProxmoxNodes verifies the proxmox check returns
// WARN when no proxmox nodes are registered.
func TestProxmoxCheck_NoProxmoxNodes(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	db, err := store.Open(filepath.Join(dir, "orca.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()

	c := Proxmox()
	r, msg := c.Run(context.Background())
	if r != ResultWarn {
		t.Errorf("Proxmox check: got %s, want WARN — %s", r, msg)
	}
	if !strings.Contains(msg, "no proxmox nodes") {
		t.Errorf("Proxmox check message should mention no proxmox nodes, got: %s", msg)
	}
}

// TestProxmoxCheck_UnreachableNode verifies the proxmox check returns
// FAIL when a proxmox node is registered but unreachable (no SSH key
// or host down). We insert a proxmox node with an unreachable address;
// the SSH dial will fail (no SSH key file → error).
func TestProxmoxCheck_UnreachableNode(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))

	db, err := store.Open(filepath.Join(dir, "orca.db"))
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	repo := store.NewNodeRepo(db)

	// Insert a proxmox node. The SSH probe will fail because no SSH
	// key exists in the test namespace dir.
	if err := repo.Insert(context.Background(), &model.Node{
		ID: "px-1", Name: "10.0.0.99", Address: "10.0.0.99:8443",
		State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
		Kind: "proxmox", OS: "pve",
	}); err != nil {
		t.Fatalf("insert: %v", err)
	}

	c := Proxmox()
	r, msg := c.Run(context.Background())
	if r != ResultFail {
		t.Errorf("Proxmox check: got %s, want FAIL — %s", r, msg)
	}
	if !strings.Contains(msg, "10.0.0.99") {
		t.Errorf("Proxmox check message should mention the node, got: %s", msg)
	}
}

func init() {
	// Suppress slog noise during tests.
	_ = os.Setenv("ORCA_LOG_LEVEL", "error")
}

// TestProxmoxCheck_FirstConnectCapturesKey verifies that the doctor
// proxmox probe uses the shared TOFU capture-fix wrapper
// (proxmox.TOFUHostKeyCallback), which captures the host key on first
// connect instead of failing with KeyError{Want:[]} (T02.9 — GRILL
// condition #2: doctor parity with bootstrap). Before T02.9, the bare
// knownhosts.New callback returned KeyError{Want:[]} on a missing
// entry and the doctor probe reported FAIL even though the node had
// joined successfully — the v0.6 ship-defect.
//
// We exercise the exact wrapper doctor.go calls against a real SSH
// server on an ephemeral port (the probe hardcodes :22, which we
// cannot bind in CI). This proves the doctor's chosen callback captures
// on first connect rather than failing — the parity guarantee.
func TestProxmoxCheck_FirstConnectCapturesKey(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)

	// Empty known_hosts (first-connect scenario).
	if err := os.WriteFile(certpaths.KnownHostsPath(), []byte{}, 0o600); err != nil {
		t.Fatalf("create known_hosts: %v", err)
	}

	// Start a fake SSH server on an ephemeral port whose host key is
	// NOT yet in known_hosts.
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	defer ln.Close()
	_, srvPriv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatalf("ed25519 gen: %v", err)
	}
	hostSigner, err := ssh.NewSignerFromKey(srvPriv)
	if err != nil {
		t.Fatalf("ssh signer: %v", err)
	}
	srvConfig := &ssh.ServerConfig{NoClientAuth: true}
	srvConfig.AddHostKey(hostSigner)
	go func() {
		for {
			nconn, err := ln.Accept()
			if err != nil {
				return
			}
			go func(c net.Conn) {
				defer c.Close()
				_, chans, reqs, err := ssh.NewServerConn(c, srvConfig)
				if err != nil {
					return
				}
				go ssh.DiscardRequests(reqs)
				for nc := range chans {
					nc.Reject(ssh.UnknownChannelType, "none")
				}
			}(nconn)
		}
	}()

	sshAddr := ln.Addr().String()
	host, _, _ := net.SplitHostPort(sshAddr)

	// The doctor probe now builds its HostKeyCallback via
	// proxmox.TOFUHostKeyCallback(sshAddr, nil). On first connect
	// (empty known_hosts) this must capture + write the key and return
	// nil, NOT a KeyError — the v0.6 ship-defect fix.
	cb, err := proxmox.TOFUHostKeyCallback(sshAddr, nil)
	if err != nil {
		t.Fatalf("TOFUHostKeyCallback: %v", err)
	}
	if err := cb(sshAddr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostSigner.PublicKey()); err != nil {
		t.Fatalf("first-connect doctor callback should capture (not fail): %v", err)
	}

	// The captured key must now be in known_hosts.
	data, err := os.ReadFile(certpaths.KnownHostsPath())
	if err != nil {
		t.Fatalf("read known_hosts: %v", err)
	}
	if len(data) == 0 {
		t.Error("known_hosts is empty — doctor capture-fix did not write the key (T02.9)")
	}
	if !strings.Contains(string(data), hostSigner.PublicKey().Type()) {
		t.Errorf("known_hosts missing the captured host key type: %s", data)
	}
}
