package felhomsshd

import (
	"context"
	"io"
	"strings"
	"sync"
	"testing"
	"time"

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

// scriptRunner records every exec and returns scripted stdout per "name+first-arg" key; scripted
// errors per the same key. Everything else returns empty success.
type scriptRunner struct {
	mu     sync.Mutex
	calls  [][]string
	stdout map[string]string
	errs   map[string]error
}

func newScriptRunner() *scriptRunner {
	return &scriptRunner{stdout: map[string]string{}, errs: map[string]error{}}
}

func key(name string, args ...string) string {
	// `nft list set inet felhom_oob <setName>` → key by the set name (last arg), so the two set reads
	// are distinguishable. Everything else keys by first arg.
	if name == "nft" && len(args) > 0 && args[0] == "list" {
		return "nft list " + args[len(args)-1]
	}
	if len(args) > 0 {
		return name + " " + args[0]
	}
	return name
}

func (r *scriptRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
	r.mu.Lock()
	r.calls = append(r.calls, append([]string{name}, args...))
	k := key(name, args...)
	out := r.stdout[k]
	err := r.errs[k]
	r.mu.Unlock()
	if err != nil {
		return nil, []byte("scripted failure"), err
	}
	return []byte(out), nil, nil
}

func (r *scriptRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
	return r.Run(ctx, name, args...)
}

func (r *scriptRunner) count(name, firstArg string) int {
	r.mu.Lock()
	defer r.mu.Unlock()
	n := 0
	for _, c := range r.calls {
		if c[0] == name && len(c) > 1 && c[1] == firstArg {
			n++
		}
	}
	return n
}

func (r *scriptRunner) sawRuleMutation() bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	for _, c := range r.calls {
		// trap 4: the agent must NEVER run `nft add rule` / `nft -f` / `nft flush ruleset|table`.
		if c[0] == "nft" && len(c) > 1 {
			if c[1] == "-f" || (len(c) > 2 && c[1] == "add" && c[2] == "rule") {
				return true
			}
			if c[1] == "flush" && len(c) > 2 && (c[2] == "ruleset" || c[2] == "table") {
				return true
			}
		}
	}
	return false
}

func TestBelt_SyncMutatesThenIdempotent(t *testing.T) {
	r := newScriptRunner()
	// first read: both sets empty (no "elements" block)
	r.stdout["nft list operator_ips"] = "set operator_ips {\n\ttype ipv4_addr\n}"
	r.stdout["nft list ssh_port"] = "set ssh_port {\n\ttype inet_service\n}"
	b := NewBelt(r, nil)
	b.SyncPort(context.Background(), 8822)
	b.SyncOperator(context.Background(), "10.77.0.250")

	// mutations happened: flush + add for BOTH sets
	if r.count("nft", "flush") < 2 || r.count("nft", "add") < 2 {
		t.Fatalf("first sync must flush+add both sets; flushes=%d adds=%d", r.count("nft", "flush"), r.count("nft", "add"))
	}
	if r.sawRuleMutation() {
		t.Fatal("belt must NEVER mutate rules — only set elements (trap 4)")
	}

	// second sync: the reads now return the desired elements → ZERO mutations (idempotent)
	r2 := newScriptRunner()
	r2.stdout["nft list operator_ips"] = "elements = { 10.77.0.250 }"
	r2.stdout["nft list ssh_port"] = "elements = { 8822 }"
	b2 := NewBelt(r2, nil)
	b2.SyncPort(context.Background(), 8822)
	b2.SyncOperator(context.Background(), "10.77.0.250")
	if r2.count("nft", "flush") != 0 || r2.count("nft", "add") != 0 {
		t.Fatalf("idempotent sync must do ZERO mutations; flushes=%d adds=%d", r2.count("nft", "flush"), r2.count("nft", "add"))
	}
}

func TestBelt_OperatorEmptyEmptiesSet(t *testing.T) {
	r := newScriptRunner()
	r.stdout["nft list operator_ips"] = "elements = { 10.77.0.250 }" // currently has an operator IP
	r.stdout["nft list ssh_port"] = "elements = { 8822 }"
	b := NewBelt(r, nil)
	b.SyncOperator(context.Background(), "") // OOB explicitly off → operator set must be emptied
	if r.count("nft", "flush") < 1 {
		t.Fatal("emptying the operator set must flush it")
	}
}

// health: inactive + INVALID config → NO restart (the red-proof); inactive + valid → restart.
func TestHeal_NoRestartOnInvalidConfig(t *testing.T) {
	r := newScriptRunner()
	r.errs["sshd -t"] = context.DeadlineExceeded // sshd -t FAILS (config invalid)
	m := newTestManager(r, false)                // inactive
	m.HealAndCheck(context.Background(), 8822)
	if r.count("systemctl", "restart") != 0 {
		t.Fatal("a broken config must NOT trigger a restart (never degraded→dead)")
	}
}

func TestHeal_RestartsWhenDownWithValidConfigThenCooldown(t *testing.T) {
	r := newScriptRunner() // sshd -t passes (no error)
	m := newTestManager(r, false)
	m.HealAndCheck(context.Background(), 8822)
	if r.count("systemctl", "restart") != 1 {
		t.Fatalf("down + valid config → exactly one restart, got %d", r.count("systemctl", "restart"))
	}
	// cooldown: a second call inside the window → still one restart
	m.HealAndCheck(context.Background(), 8822)
	if r.count("systemctl", "restart") != 1 {
		t.Fatalf("restart cooldown violated: %d restarts", r.count("systemctl", "restart"))
	}
}

func TestStatus_ReflectsBlockAndPort(t *testing.T) {
	r := newScriptRunner()
	r.stdout["sshd -T"] = "port 8822\nsomethingelse yes\n"
	m := newTestManager(r, true) // active
	m.port = 8822
	st := m.Status(context.Background(), &hub.WireWireguard{OOBPeerIP: "10.77.0.250", OOBOperatorSSHKey: "ssh-ed25519 AAAA op"})
	if !st.FelhomSshdActive || st.FelhomSshdPort != 8822 {
		t.Fatalf("status: %+v", st)
	}
	if !st.OperatorPeerConfigured || !st.OperatorKeyConfigured {
		t.Fatalf("status must reflect operator peer + key configured: %+v", st)
	}
}

// newTestManager builds a Manager with injected active-state + a now clock; sshd -T/-t go through the
// runner. isFree unused here.
func newTestManager(r *scriptRunner, active bool) *Manager {
	m := NewManager(r, "/tmp/felhomsshd-test", nil)
	m.isActive = func(context.Context) bool { return active }
	m.isFailed = func(context.Context) bool { return false }
	m.now = func() time.Time { return time.Unix(1783270000, 0) }
	return m
}

func TestConfig_NoRuntimeDirectoryString(t *testing.T) {
	c, _ := renderConfig(2222)
	if strings.Contains(c, "RuntimeDirectory") {
		t.Fatal("SF-1: config must never contain RuntimeDirectory")
	}
}
