// Package emitter: nft.go implements the nftables emitter for the
// R-017 ingress hybrid model (P15.5, REQ-099). Orca's ingress is
// "hybrid": Traefik binds the loopback 127.0.0.1:8443 (mTLS, app L7)
// and nftables DNATs the public :443 to 127.0.0.1:8443 so the kernel
// owns the public-facing surface (D-218: modern nft, not legacy
// iptables; D-217: idempotent `nft -f` apply).
//
// The rendered file /etc/nftables.d/orca.nft is a self-contained nft
// ruleset fragment. It starts with `#!/usr/sbin/nft -f` so it can be
// applied directly (`nft -f /etc/nftables.d/orca.nft`) and is
// idempotent: the table is flushed+recreated on each apply (D-217).
//
// The ruleset defines:
//
//   - table inet orca-ingress
//   - set orca_trusted_probes_v4 (ipv4_addr interval, default 127.0.0.1)
//   - set orca_trusted_probes_v6 (ipv6_addr interval, default ::1)
//   - input chain (SYN-flood filter on :443)
//   - prerouting chain (DNAT :443->127.0.0.1:8443, :80->127.0.0.1:8080)
//   - forward chain (rate-limit meter on :443)
//
// The emitter is pure (no I/O): RenderNftConfig returns a []File; the
// SSH-push transport writes the file to each peer.
package emitter

import (
	"errors"
	"fmt"
	"net"
	"strings"
)

// NftEmitter renders the R-017 nftables ingress ruleset (REQ-099).
type NftEmitter struct{}

// nftConfigPath is the canonical on-peer path for the rendered
// ruleset. systemd-locale-independent; nft -f loads it on boot.
const nftConfigPath = "/etc/nftables.d/orca.nft"

// NftClusterConfig carries the cluster-wide knobs the emitter needs.
// All fields have safe defaults so a zero-value config renders a
// working ruleset.
type NftClusterConfig struct {
	// TrustedProbes is the list of source IPs/CIDRs exempt from the
	// SYN-flood filter and rate-limit (monitoring probes, the orca
	// lead itself). Defaults to [127.0.0.1, ::1]. Each entry must
	// parse as a valid IP or CIDR via net.ParseIP / net.ParseCIDR or
	// RenderNftConfig returns an error (F9: ruleset injection guard).
	TrustedProbes []string
	// RateLimit is the per-source rate limit (packets/second) for the
	// forward-chain meter on :443. Defaults to 100.
	RateLimit int
	// RateBurst is the per-source burst (packets) for the meter.
	// Defaults to 200.
	RateBurst int
}

// withDefaults returns a copy of c with zero values replaced by the
// canonical defaults.
func (c NftClusterConfig) withDefaults() NftClusterConfig {
	out := c
	if len(out.TrustedProbes) == 0 {
		out.TrustedProbes = []string{"127.0.0.1", "::1"}
	}
	if out.RateLimit <= 0 {
		out.RateLimit = 100
	}
	if out.RateBurst <= 0 {
		out.RateBurst = 200
	}
	return out
}

// RenderNftConfig renders the /etc/nftables.d/orca.nft file for the
// given cluster config. The output is a single File whose Path is
// nftConfigPath, Mode is 0644, and Content starts with the
// `#!/usr/sbin/nft -f` shebang (so `nft -f` applies it and so a
// drift-check `nft -c -f` validates the syntax).
//
// Returns an error when the config is internally inconsistent (e.g. a
// negative rate, which the defaults already prevent) or when a
// TrustedProbes entry fails to parse as an IP or CIDR (F9: ruleset
// injection hardening — unvalidated entries are written directly into
// the nft ruleset and could inject arbitrary nft syntax).
func (NftEmitter) RenderNftConfig(clusterConfig NftClusterConfig) ([]File, error) {
	if clusterConfig.RateLimit < 0 || clusterConfig.RateBurst < 0 {
		return nil, errors.New("emitter/nft: rate/burst must be non-negative")
	}
	cfg := clusterConfig.withDefaults()
	// F9: validate every TrustedProbes entry before rendering. An
	// invalid entry is rejected with an error rather than written raw
	// into the ruleset (which would allow nft-syntax injection).
	v4, v6, err := partitionTrustedProbes(cfg.TrustedProbes)
	if err != nil {
		return nil, err
	}
	content := renderNftRuleset(cfg, v4, v6)
	return []File{{Path: nftConfigPath, Content: content, Mode: "0644"}}, nil
}

// partitionTrustedProbes validates each entry as an IP or CIDR and
// partitions the list into IPv4 and IPv6 slices. Returns an error if
// any entry is neither a valid IP nor a valid CIDR (F9).
func partitionTrustedProbes(probes []string) (v4, v6 []string, err error) {
	for _, p := range probes {
		if p == "" {
			return nil, nil, fmt.Errorf("emitter/nft: empty trusted probe entry (F9: ruleset injection guard)")
		}
		if ip := net.ParseIP(p); ip != nil {
			if ip.To4() != nil {
				v4 = append(v4, p)
			} else {
				v6 = append(v6, p)
			}
			continue
		}
		if _, _, cidrErr := net.ParseCIDR(p); cidrErr == nil {
			// Determine address family from the CIDR prefix.
			ip := net.ParseIP(strings.Split(p, "/")[0])
			if ip != nil && ip.To4() != nil {
				v4 = append(v4, p)
			} else {
				v6 = append(v6, p)
			}
			continue
		}
		return nil, nil, fmt.Errorf("emitter/nft: trusted probe %q is not a valid IP or CIDR (F9: ruleset injection guard)", p)
	}
	return v4, v6, nil
}

// renderNftRuleset builds the nft ruleset string. The shape is
// documented in the package comment; the exact lines are load-bearing
// for `orca doctor nft` (which greps the live table for them) and for
// `nft -c -f` (which parses the syntax).
//
// F9: TrustedProbes are split into separate ipv4_addr and ipv6_addr
// sets (orca_trusted_probes_v4 / orca_trusted_probes_v6) because the
// prior single ipv4_addr set included ::1 (an IPv6 address), which is
// a type mismatch nft rejects.
func renderNftRuleset(cfg NftClusterConfig, v4, v6 []string) string {
	var b strings.Builder
	b.WriteString("#!/usr/sbin/nft -f\n\n")
	b.WriteString("flush table inet orca-ingress\n\n")
	b.WriteString("table inet orca-ingress {\n")

	// F9: split IPv4 and IPv6 trusted probes into separate typed sets.
	b.WriteString("\tset orca_trusted_probes_v4 {\n")
	b.WriteString("\t\ttype ipv4_addr\n")
	b.WriteString("\t\tflags interval\n")
	b.WriteString("\t\telements = { ")
	for i, p := range v4 {
		if i > 0 {
			b.WriteString(", ")
		}
		b.WriteString(p)
	}
	b.WriteString(" }\n")
	b.WriteString("\t}\n\n")

	b.WriteString("\tset orca_trusted_probes_v6 {\n")
	b.WriteString("\t\ttype ipv6_addr\n")
	b.WriteString("\t\tflags interval\n")
	b.WriteString("\t\telements = { ")
	for i, p := range v6 {
		if i > 0 {
			b.WriteString(", ")
		}
		b.WriteString(p)
	}
	b.WriteString(" }\n")
	b.WriteString("\t}\n\n")

	b.WriteString("\tchain input {\n")
	b.WriteString("\t\ttype filter hook input priority filter; policy accept;\n")
	b.WriteString("\t\tct state invalid drop\n")
	b.WriteString("\t\tct state established,related accept\n")
	b.WriteString("\t\ttcp dport 443 tcp-flags != syn,rst,ack,fin notrack drop\n")
	b.WriteString("\t}\n\n")
	b.WriteString("\tchain prerouting {\n")
	b.WriteString("\t\ttype nat hook prerouting priority -100; policy accept;\n")
	b.WriteString("\t\ttcp dport 443 dnat to 127.0.0.1:8443\n")
	b.WriteString("\t\ttcp dport 80 dnat to 127.0.0.1:8080\n")
	b.WriteString("\t}\n\n")
	b.WriteString("\tchain forward {\n")
	b.WriteString("\t\ttype filter hook forward priority filter; policy accept;\n")
	b.WriteString(fmt.Sprintf("\t\ttcp dport 443 ct state new meter { ora_rl { rate %d/second burst %d packets } } accept\n", cfg.RateLimit, cfg.RateBurst))
	b.WriteString("\t}\n")
	b.WriteString("}\n")
	return b.String()
}
