package felhomsshd

import (
	"context"
	"log/slog"
	"net/netip"
	"regexp"
	"sort"
	"strconv"
	"strings"

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

// Belt keeps the STATIC nft table `inet felhom_oob` (installed by host-install) converged on the
// current OOB state by mutating only its SETS — @operator_ips (the operator /32 allowed to reach
// felhom-sshd over wg-felhom) and @ssh_port (felhom-sshd's claimed port). The agent NEVER adds/removes
// RULES [trap 4]: set-element mutation can't change rule semantics, so the narrow sudoers grant stays
// safe. Idempotent: reads the current elements and mutates only on a difference (no churn).
type Belt struct {
	runner proxmox.Runner
	logger *slog.Logger

	table string // "felhom_oob"
}

// NewBelt builds a Belt over the narrow runner. table defaults to "felhom_oob".
func NewBelt(runner proxmox.Runner, logger *slog.Logger) *Belt {
	if logger == nil {
		logger = slog.Default()
	}
	return &Belt{runner: runner, logger: logger, table: "felhom_oob"}
}

// SyncPort converges @ssh_port on the claimed port (0 empties it). Always safe to call — the port
// comes from the agent's own claim, not the hub desired-state.
func (b *Belt) SyncPort(ctx context.Context, port int) {
	wantPorts := []string{}
	if port > 0 && port <= 65535 {
		wantPorts = []string{strconv.Itoa(port)}
	}
	b.syncSet(ctx, "ssh_port", wantPorts)
}

// SyncOperator converges @operator_ips on the operator /32 (operatorIP "" = OOB explicitly off →
// empty the set). Call ONLY when the desired-state has actually been FETCHED — a nil/unfetched block
// must NOT empty the set (that would lock the operator out until the next fetch, the wgtunnel
// fetched=false-is-never-a-teardown rule). Every value is netip-validated before it reaches nft.
func (b *Belt) SyncOperator(ctx context.Context, operatorIP string) {
	wantOps := []string{}
	if operatorIP != "" {
		ip, err := netip.ParseAddr(operatorIP)
		if err != nil || !ip.Is4() {
			b.logger.Error("felhomsshd belt: operator ip invalid — refusing", "ip", operatorIP)
			return
		}
		wantOps = []string{ip.String()}
	}
	b.syncSet(ctx, "operator_ips", wantOps)
}

var nftElemRe = regexp.MustCompile(`elements\s*=\s*\{([^}]*)\}`)

// syncSet converges one named set on `want` (sorted, deduped). Reads current elements; if they match,
// ZERO nft mutations (the idempotency the scenario asserts). Otherwise flush + add the desired
// elements. A read failure (table/set absent) → one warning, no mutation.
func (b *Belt) syncSet(ctx context.Context, setName string, want []string) {
	want = sortedUnique(want)
	cur, ok := b.readSet(ctx, setName)
	if !ok {
		b.logger.Warn("felhomsshd belt: set unreadable (table not installed?) — skipping", "set", setName)
		return
	}
	if equalStringSlices(cur, want) {
		return // steady state: no mutation
	}
	if _, errOut, err := b.runner.Run(ctx, "nft", "flush", "set", "inet", b.table, setName); err != nil {
		b.logger.Error("felhomsshd belt: flush failed", "set", setName, "err", err, "stderr", strings.TrimSpace(string(errOut)))
		return
	}
	for _, e := range want {
		if _, errOut, err := b.runner.Run(ctx, "nft", "add", "element", "inet", b.table, setName, "{ "+e+" }"); err != nil {
			b.logger.Error("felhomsshd belt: add element failed", "set", setName, "elem", e, "err", err, "stderr", strings.TrimSpace(string(errOut)))
			return
		}
	}
	b.logger.Info("felhomsshd belt: set synced", "set", setName, "elements", strings.Join(want, ","))
}

// readSet returns the current elements of a named set (sorted), or ok=false if the set can't be read.
func (b *Belt) readSet(ctx context.Context, setName string) ([]string, bool) {
	out, _, err := b.runner.Run(ctx, "nft", "list", "set", "inet", b.table, setName)
	if err != nil {
		return nil, false
	}
	m := nftElemRe.FindSubmatch(out)
	if m == nil {
		return []string{}, true // set exists but empty (no "elements = {}" block)
	}
	var elems []string
	for _, f := range strings.Split(string(m[1]), ",") {
		f = strings.TrimSpace(f)
		if f != "" {
			elems = append(elems, f)
		}
	}
	return sortedUnique(elems), true
}

func sortedUnique(in []string) []string {
	seen := map[string]bool{}
	out := make([]string, 0, len(in))
	for _, s := range in {
		if !seen[s] {
			seen[s] = true
			out = append(out, s)
		}
	}
	sort.Strings(out)
	return out
}

func equalStringSlices(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
