package cli

import (
	"bytes"
	"context"
	"errors"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

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

type mockRotateTransport struct {
	mu       sync.Mutex
	calls    []mockRotateCall
	written  []mockRotateWrite
	responses []mockRotateResp
	sticky   []mockRotateResp
}

type mockRotateCall struct {
	peer string
	cmd  string
}

type mockRotateWrite struct {
	peer    string
	path    string
	content []byte
	mode    os.FileMode
}

type mockRotateResp struct {
	match string
	out   string
	exit  int
}

func (m *mockRotateTransport) Exec(_ context.Context, peer, cmd string) ([]byte, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.calls = append(m.calls, mockRotateCall{peer: peer, cmd: cmd})
	for _, r := range m.sticky {
		if r.match == "" || strings.Contains(cmd, r.match) {
			if r.exit != 0 {
				return []byte(r.out), &sshExitErr{code: r.exit}
			}
			return []byte(r.out), nil
		}
	}
	for i, r := range m.responses {
		if r.match == "" || strings.Contains(cmd, r.match) {
			m.responses = append(m.responses[:i], m.responses[i+1:]...)
			if r.exit != 0 {
				return []byte(r.out), &sshExitErr{code: r.exit}
			}
			return []byte(r.out), nil
		}
	}
	return nil, nil
}

func (m *mockRotateTransport) WriteFileIdempotent(_ context.Context, peer, path string, content []byte, mode os.FileMode) (bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.written = append(m.written, mockRotateWrite{peer: peer, path: path, content: content, mode: mode})
	return true, nil
}

func (m *mockRotateTransport) ReadFile(_ context.Context, peer, path string) ([]byte, error) {
	return nil, errors.New("not implemented")
}

func (m *mockRotateTransport) countCalls(match string) int {
	m.mu.Lock()
	defer m.mu.Unlock()
	c := 0
	for _, call := range m.calls {
		if strings.Contains(call.cmd, match) {
			c++
		}
	}
	return c
}

func (m *mockRotateTransport) writtenPaths() []string {
	m.mu.Lock()
	defer m.mu.Unlock()
	out := make([]string, 0, len(m.written))
	for _, w := range m.written {
		out = append(out, w.path)
	}
	return out
}

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

func TestCutover_StopsDaemonAndAdoptsAllocs(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "peer-a")
	cutoverTestNode(t, "peer-b")

	mx := &scriptedDrainExec{}
	mx.queueAlways("systemctl stop orca-daemon.service", "", 0)
	mx.queueAlways("list-units", "orca-alloc-web-0.service loaded active running\n", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	clusterCmd.SetOut(&buf)
	clusterCmd.SetErr(&buf)
	clusterCutoverCmd.SetOut(&buf)
	clusterCutoverCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "cutover", "--timeout", "10s"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("cutover: %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "cutover complete") {
		t.Errorf("expected cutover complete, got: %s", out)
	}
	stops := mx.countCalls("systemctl stop orca-daemon.service")
	if stops != 2 {
		t.Errorf("expected 2 daemon stops, got %d", stops)
	}
	if !strings.Contains(out, "adopted") {
		t.Errorf("expected adopted in output, got: %s", out)
	}
}

func TestCutover_AlreadyStoppedIsIdempotent(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "migrated")

	mx := &scriptedDrainExec{}
	mx.queueAlways("systemctl stop orca-daemon.service", "", 5)
	mx.queueAlways("list-units", "", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	clusterCutoverCmd.SetOut(&buf)
	clusterCutoverCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "cutover", "--timeout", "5s"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("cutover should be idempotent: %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "already stopped") && !strings.Contains(out, "already-stopped") {
		t.Errorf("expected already-stopped, got: %s", out)
	}
}

func TestRotateLead_CopiesClusterStateAndRotatesSSHKeys(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	target := cutoverTestNode(t, "newlead")
	_ = cutoverTestNode(t, "other-peer")

	clusterDir := paths.ClusterDir()
	if err := os.MkdirAll(clusterDir, 0o755); err != nil {
		t.Fatalf("mkdir cluster dir: %v", err)
	}
	if err := os.WriteFile(certpaths.CACertPath(), []byte("FAKE-CA-CRT"), 0o644); err != nil {
		t.Fatalf("write ca.crt: %v", err)
	}
	if err := os.WriteFile(certpaths.CAKeyPath(), []byte("FAKE-CA-KEY"), 0o600); err != nil {
		t.Fatalf("write ca.key: %v", err)
	}
	if err := os.WriteFile(paths.MasterKeyPath(), []byte("FAKE-MASTER-KEY"), 0o600); err != nil {
		t.Fatalf("write master.key: %v", err)
	}
	if err := os.WriteFile(paths.ConfigPath(), []byte("# orca config"), 0o644); err != nil {
		t.Fatalf("write config.md: %v", err)
	}

	mt := &mockRotateTransport{}
	mt.sticky = append(mt.sticky, mockRotateResp{match: "mkdir -p", out: "", exit: 0})
	mt.sticky = append(mt.sticky, mockRotateResp{match: "authorized_keys", out: "", exit: 0})
	driftTransportOverride = mt

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	clusterCmd.SetOut(&buf)
	clusterCmd.SetErr(&buf)
	clusterRotateLeadCmd.SetOut(&buf)
	clusterRotateLeadCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "rotate-lead", "--to", target.Name})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("rotate-lead: %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "lead rotated") {
		t.Errorf("expected lead rotated, got: %s", out)
	}
	written := mt.writtenPaths()
	if !containsPath(written, "/etc/orca/cluster/ca.key") {
		t.Errorf("expected ca.key to be copied, written: %v", written)
	}
	if !containsPath(written, "/etc/orca/cluster/master.key") {
		t.Errorf("expected master.key to be copied, written: %v", written)
	}

	lead, err := readCurrentLead(context.Background())
	if err != nil {
		t.Fatalf("read lead: %v", err)
	}
	if lead != target.Name {
		t.Errorf("lead = %q, want %q", lead, target.Name)
	}

	newKey, err := os.ReadFile(certpaths.SSHKeyPath())
	if err != nil {
		t.Fatalf("read new ssh key: %v", err)
	}
	if !strings.Contains(string(newKey), "PRIVATE KEY") {
		t.Errorf("expected a new private key to be written, got: %s", string(newKey))
	}
}

func TestRotateLead_ProxmoxTargetRefused(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	db, err := store.Open(certpaths.DBPath())
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	proxmoxNode := &model.Node{
		ID:       "node-prox",
		Name:     "prox-node",
		Address:  "prox-node:8443",
		State:    model.NodeStateReady,
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
		Kind:     string(model.NodeKindProxmox),
	}
	if err := store.NewNodeRepo(db).Insert(context.Background(), proxmoxNode); err != nil {
		t.Fatalf("insert proxmox node: %v", err)
	}

	mt := &mockRotateTransport{}
	driftTransportOverride = mt

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	clusterRotateLeadCmd.SetOut(&buf)
	clusterRotateLeadCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "rotate-lead", "--to", "prox-node"})
	err = rootCmd.Execute()
	if err == nil {
		t.Fatal("expected error for Proxmox target, got nil")
	}
	if !errors.Is(err, cluster.ErrProxmoxNotLead) && !strings.Contains(err.Error(), "Proxmox") {
		t.Errorf("expected Proxmox refusal, got: %v", err)
	}
}

func TestRotateLead_AlreadyLeadIsNoop(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	target := cutoverTestNode(t, "currentlead")
	if err := writeCurrentLead(context.Background(), target.Name); err != nil {
		t.Fatalf("write lead: %v", err)
	}

	mt := &mockRotateTransport{}
	driftTransportOverride = mt

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	clusterRotateLeadCmd.SetOut(&buf)
	clusterRotateLeadCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "rotate-lead", "--to", target.Name})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("rotate-lead should be no-op when already lead: %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "already") {
		t.Errorf("expected already-lead message, got: %s", out)
	}
	if len(mt.calls) != 0 {
		t.Errorf("expected no SSH calls for no-op, got: %+v", mt.calls)
	}
}

func TestNoOrcaOnServer_CleanPeerPasses(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "clean-peer")

	mx := &scriptedDrainExec{}
	mx.queueAlways("command -v orca", "", 0)
	mx.queueAlways("systemctl list-units", "", 0)
	mx.queueAlways("pgrep", "", 0)
	mx.queueAlways("find /etc/orca", "", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	noOrcaOnServerCmd.SetOut(&buf)
	noOrcaOnServerCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"doctor", "no-orca-on-server"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("no-orca-on-server (clean): %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "PASS") {
		t.Errorf("expected PASS for clean peer, got: %s", out)
	}
	if !strings.Contains(out, "all peers clean") {
		t.Errorf("expected all-peers-clean message, got: %s", out)
	}
}

func TestNoOrcaOnServer_DirtyPeerBinaryReportsViolation(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "dirty-bin")

	mx := &scriptedDrainExec{}
	mx.queueAlways("command -v orca", "/usr/local/bin/orca\n", 0)
	mx.queueAlways("systemctl list-units", "", 0)
	mx.queueAlways("pgrep", "", 0)
	mx.queueAlways("find /etc/orca", "", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	noOrcaOnServerCmd.SetOut(&buf)
	noOrcaOnServerCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"doctor", "no-orca-on-server"})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("expected error for dirty peer, got nil")
	}
	out := buf.String()
	if !strings.Contains(out, "FAIL") {
		t.Errorf("expected FAIL for dirty peer, got: %s", out)
	}
	if !strings.Contains(out, "binary in PATH") {
		t.Errorf("expected binary-in-PATH violation, got: %s", out)
	}
}

func TestNoOrcaOnServer_DirtyPeerProcessReportsViolation(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "dirty-proc")

	mx := &scriptedDrainExec{}
	mx.queueAlways("command -v orca", "", 0)
	mx.queueAlways("systemctl list-units", "", 0)
	mx.queueAlways("pgrep", "12345\n", 0)
	mx.queueAlways("find /etc/orca", "", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	noOrcaOnServerCmd.SetOut(&buf)
	noOrcaOnServerCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"doctor", "no-orca-on-server"})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("expected error for dirty peer (process), got nil")
	}
	out := buf.String()
	if !strings.Contains(out, "process running") {
		t.Errorf("expected process-running violation, got: %s", out)
	}
}

func TestCompatCheck_AllSameVersionPasses(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "peer-a")
	cutoverTestNode(t, "peer-b")

	mx := &scriptedDrainExec{}
	mx.queueAlways("orca version", "{\"version\":\""+version+"\"}\n", 0)
	mx.queueAlways("test -f /etc/orca/cluster/render-contract.json", "", 0)
	mx.queueAlways("test -d /etc/orca/cluster/txns", "", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	compatCheckCmd.SetOut(&buf)
	compatCheckCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "compat-check"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("compat-check (same): %v", err)
	}
	out := buf.String()
	if !strings.Contains(out, "all peers compatible") {
		t.Errorf("expected all-peers-compatible, got: %s", out)
	}
}

func TestCompatCheck_MixedCompatiblePasses(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "peer-a")

	mx := &scriptedDrainExec{}
	mx.queueAlways("orca version", "{\"version\":\"0.1.1\"}\n", 0)
	mx.queueAlways("test -f /etc/orca/cluster/render-contract.json", "", 0)
	mx.queueAlways("test -d /etc/orca/cluster/txns", "", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	compatCheckCmd.SetOut(&buf)
	compatCheckCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "compat-check"})
	if err := rootCmd.Execute(); err != nil {
		t.Logf("output: %s", buf.String())
		t.Fatalf("compat-check (mixed-compatible): %v", err)
	}
}

func TestCompatCheck_IncompatibleReportsIssue(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	cutoverTestNode(t, "peer-old")

	mx := &scriptedDrainExec{}
	mx.queueAlways("orca version", "{\"version\":\"0.8.0\"}\n", 0)
	mx.queueAlways("test -f /etc/orca/cluster/render-contract.json", "", 0)
	mx.queueAlways("test -d /etc/orca/cluster/txns", "", 0)
	drainExecOverride = mx

	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	compatCheckCmd.SetOut(&buf)
	compatCheckCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"cluster", "compat-check"})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("expected compat-check to fail for incompatible versions, got nil")
	}
	out := buf.String()
	if !strings.Contains(out, "incompatible") {
		t.Errorf("expected incompatible in output, got: %s", out)
	}
}

func containsPath(paths []string, want string) bool {
	for _, p := range paths {
		if p == want {
			return true
		}
	}
	return false
}

func init() {}
