package emitter

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

	"git.cloudinit.dev/coreci/orca/internal/jobspec"
)

// TraefikEmitter is the Layer-4 emitter for the Traefik dynamic-config
// file (REQ-077). It renders /etc/traefik/dynamic/orca-<spec.Name>.yaml
// — a single Traefik dynamic-config file describing the routers,
// services (servers = the R-007 socket paths), TLS config pointing at
// the step-ca root CA, and the service health check.
//
// Registered on the emitter.Registry under the service-kind keys:
//
//   - service:process
//   - service:podman
//   - service:wasm
//
// RegisterTraefik wires all three; callers can also call Register
// directly with TraefikEmitter{} for a single runtime.
//
// Atomic reload (gate C-10): the Traefik dynamic-config file is written
// atomically via the SSH-push transport (sshpush.WriteFileIdempotent
// performs temp-file + fsync + rename, and WriteTraefikDynamic wraps
// it with an explicit tmp+mv so fsnotify sees a single rename event).
// Traefik watches the dynamic dir with fsnotify; the rename triggers a
// reload. On a malformed config Traefik logs an error and holds the
// last-good config (documented Traefik behavior; the C-10 test
// verifies the tmp+rename sequence so a half-written file is never
// observed by Traefik). Drain is rendered by setting the backend
// server's weight to 0 (or removing it) — see RenderDrain.
//
// The orca-v1- prefix is NOT applied to Traefik dynamic-config paths
// (the prefix is only for systemd unit names; the Traefik file is named
// orca-<spec.Name>.yaml and is the single source of truth for the
// service route — there is no dual-write window for Traefik configs).
type TraefikEmitter struct{}

// traefikDynamicDir is the canonical Traefik dynamic-config directory
// (R-006). The emitter writes one file per service at
// /etc/traefik/dynamic/orca-<spec.Name>.yaml.
const traefikDynamicDir = "/etc/traefik/dynamic"

// traefikRouterTLSCertResolver is the Traefik cert-resolver name that
// the orca step-ca integration configures on the Traefik static config
// (P10 / v0.10 wires the step-ca root into this resolver). The
// dynamic-config file references it by name.
const traefikRouterTLSCertResolver = "orca"

// defaultTrustDomain is the SPIFFE trust domain used in the rendered
// TLS stanza when the spec does not carry an explicit trust domain.
// The step-ca provisioner (P10) overrides this at render time via the
// node argument; for P02 the emitter renders the placeholder.
const defaultTrustDomain = "cluster.orca.local"

// Render renders the Traefik dynamic-config YAML for a Service
// workload. The output is a single File whose Path is
// /etc/traefik/dynamic/orca-<spec.Name>.yaml, Content is the rendered
// YAML, and Mode is 0644.
//
// Returns an error if the spec is nil, the name is empty, the spec has
// no ports (a Service with no ports has no backends to route to), or a
// service.bind value (when present) is not a valid IP address (R-007).
func (TraefikEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
	if spec == nil {
		return nil, errors.New("emitter/traefik: spec is nil")
	}
	if strings.TrimSpace(spec.Name) == "" {
		return nil, errors.New("emitter/traefik: spec name is empty")
	}
	if len(spec.Ports) == 0 {
		return nil, errors.New("emitter/traefik: service has no ports (no backends to route to)")
	}
	if spec.Service != nil {
		if b := strings.TrimSpace(spec.Service.Bind); b != "" && net.ParseIP(b) == nil {
			return nil, fmt.Errorf("emitter/traefik: service.bind %q is not a valid IP (R-007)", b)
		}
	}
	content, err := renderTraefikYAML(spec, node)
	if err != nil {
		return nil, err
	}
	path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
	return []File{{Path: path, Content: content, Mode: "0644"}}, nil
}

// RenderDrain renders a Traefik dynamic-config that drains the service
// by setting every backend server's weight to 0 (I-B-005 drain). The
// path matches the live config so the atomic rename overwrites the
// routing config with the drained config (Traefik reloads and stops
// sending traffic). The caller writes the result via
// WriteTraefikDynamic for the C-10 atomicity protocol.
func (e TraefikEmitter) RenderDrain(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
	if spec == nil {
		return nil, errors.New("emitter/traefik: spec is nil")
	}
	if strings.TrimSpace(spec.Name) == "" {
		return nil, errors.New("emitter/traefik: spec name is empty")
	}
	if len(spec.Ports) == 0 {
		return nil, errors.New("emitter/traefik: service has no ports (no backends to drain)")
	}
	content, err := renderTraefikYAMLDrain(spec, node)
	if err != nil {
		return nil, err
	}
	path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
	return []File{{Path: path, Content: content, Mode: "0644"}}, nil
}

// RegisterTraefik registers the TraefikEmitter on the given Registry
// under the three service-kind runtime keys (service:process,
// service:podman, service:wasm). The emitter is the same instance for
// all three runtimes — the rendered Traefik config is runtime-agnostic
// (the backend server URL is the R-007 socket path, which the runtime
// layer binds regardless of process/wasm/podman).
func RegisterTraefik(reg *Registry) {
	e := TraefikEmitter{}
	reg.Register("service:process", e)
	reg.Register("service:podman", e)
	reg.Register("service:wasm", e)
}

// renderTraefikYAML renders the Traefik dynamic-config YAML for the
// given spec + node. The shape (verified by the Traefik docs) is:
//
//	http:
//	  routers:
//	    orca-<name>:
//	      rule: PathPrefix("/<name>")
//	      service: orca-<name>
//	      tls:
//	        certResolver: orca
//	        domains:
//	          - main: "<trust-domain>"
//	  services:
//	    orca-<name>:
//	      loadBalancer:
//	        servers:
//	          - url: "unix:///run/orca/alloc-<allocID>/port-<portName>.sock"
//	        healthCheck:
//	          path: /healthz
//	          interval: <interval>
//	          timeout: <timeout>
//
// The alloc-id placeholder is "<allocID>" pending the P08 socket
// layer; Traefik will reject the URL until a real alloc-id is
// substituted. For P02 the emitter renders the placeholder so the
// C-10 atomicity protocol is testable end-to-end; the socket layer
// (P08) replaces the placeholder with the live alloc-id.
func renderTraefikYAML(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
	return renderTraefikYAMLWeighted(spec, node, false)
}

// renderTraefikYAMLDrain renders the drained Traefik dynamic-config
// (every backend server has weight: 0). The shape mirrors the live
// config so the rename overwrites the live route with the drain.
func renderTraefikYAMLDrain(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
	return renderTraefikYAMLWeighted(spec, node, true)
}

// renderTraefikYAMLWeighted renders the Traefik dynamic-config YAML.
// When drain is true, every server entry is emitted with `weight: 0`
// (I-B-005). When drain is false, no weight is emitted (Traefik
// defaults to 1 — equal weighting across servers).
func renderTraefikYAMLWeighted(spec *jobspec.WorkloadSpec, node *Node, drain bool) (string, error) {
	var b strings.Builder
	routerName := "orca-" + spec.Name
	serviceName := "orca-" + spec.Name
	rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name)
	trustDomain := defaultTrustDomain

	b.WriteString("http:\n")
	b.WriteString("  routers:\n")
	b.WriteString(fmt.Sprintf("    %s:\n", routerName))
	b.WriteString(fmt.Sprintf("      rule: %s\n", rule))
	b.WriteString(fmt.Sprintf("      service: %s\n", serviceName))
	b.WriteString("      tls:\n")
	b.WriteString(fmt.Sprintf("        certResolver: %s\n", traefikRouterTLSCertResolver))
	b.WriteString("        domains:\n")
	b.WriteString(fmt.Sprintf("          - main: %q\n", trustDomain))
	b.WriteString("  services:\n")
	b.WriteString(fmt.Sprintf("    %s:\n", serviceName))
	b.WriteString("      loadBalancer:\n")
	b.WriteString("        servers:\n")
	allocID := allocIDFor(node)
	for _, p := range spec.Ports {
		sock := fmt.Sprintf("unix:///run/orca/alloc-%s/port-%s.sock", allocID, p.Name)
		b.WriteString("          - url: ")
		b.WriteString(fmt.Sprintf("%q\n", sock))
		if drain {
			b.WriteString("            weight: 0\n")
		}
	}
	if spec.Health != nil {
		b.WriteString("        healthCheck:\n")
		path := "/healthz"
		b.WriteString(fmt.Sprintf("          path: %s\n", path))
		if spec.Health.Interval != "" {
			b.WriteString(fmt.Sprintf("          interval: %s\n", spec.Health.Interval))
		}
		if spec.Health.Timeout != "" {
			b.WriteString(fmt.Sprintf("          timeout: %s\n", spec.Health.Timeout))
		}
	}
	return b.String(), nil
}

// allocIDFor returns the alloc-id placeholder for the node. P08 will
// substitute the live alloc-id from the socket layer; for P02 we use a
// deterministic placeholder derived from the node hostname so the
// rendered config is stable across re-renders (the C-10 idempotency
// check depends on a stable hash). When the node is nil or has no
// hostname, the literal placeholder "<allocID>" is emitted.
func allocIDFor(node *Node) string {
	if node == nil || strings.TrimSpace(node.Hostname) == "" {
		return "<allocID>"
	}
	return node.Hostname
}

// traefikStaticConfigPath is the canonical on-peer path for the
// Traefik static config (R-017, D-220).
const traefikStaticConfigPath = "/etc/traefik/traefik.yml"

// TraefikStaticOpts controls the Traefik static-config binding. The
// default (PublicBinding="hybrid", R-017) binds the entrypoints to the
// loopback so nftables owns the public surface; the opt-out
// ("traefik-on-public-ip") binds them to the wildcard so Traefik owns
// the public surface directly (legacy / single-host deployments).
type TraefikStaticOpts struct {
	// PublicBinding selects the binding model:
	//   "hybrid" (default, R-017): loopback bind + nft DNAT.
	//   "traefik-on-public-ip": Traefik binds :443/:80 directly.
	PublicBinding string
}

// withDefaults returns a copy of o with the default PublicBinding
// applied when empty.
func (o TraefikStaticOpts) withDefaults() TraefikStaticOpts {
	out := o
	if strings.TrimSpace(out.PublicBinding) == "" {
		out.PublicBinding = "hybrid"
	}
	return out
}

func (o TraefikStaticOpts) publicWebSecure() string {
	if o.PublicBinding == "traefik-on-public-ip" {
		return ":443"
	}
	return "127.0.0.1:8443"
}
func (o TraefikStaticOpts) publicWeb() string {
	if o.PublicBinding == "traefik-on-public-ip" {
		return ":80"
	}
	return "127.0.0.1:8080"
}

// RenderTraefikStaticConfig renders /etc/traefik/traefik.yml (REQ-100,
// D-220). The static config pins the entrypoint bind addresses (R-017
// hybrid default: loopback + nft DNAT; opt-out: Traefik on the public
// IP), the file provider (watch=/etc/traefik/dynamic/orca.yml), and
// json log + access log.
func (TraefikEmitter) RenderTraefikStaticConfig(opts TraefikStaticOpts) ([]File, error) {
	o := opts.withDefaults()
	content := renderTraefikStaticYAML(o)
	return []File{{Path: traefikStaticConfigPath, Content: content, Mode: "0644"}}, nil
}

// renderTraefikStaticYAML builds the static-config YAML. The shape is
// load-bearing for `orca doctor ingress` (which greps the live file
// for the bind addresses).
func renderTraefikStaticYAML(o TraefikStaticOpts) string {
	var b strings.Builder
	b.WriteString("entryPoints:\n")
	b.WriteString("  websecure:\n")
	b.WriteString(fmt.Sprintf("    address: %q\n", o.publicWebSecure()))
	b.WriteString("  web:\n")
	b.WriteString(fmt.Sprintf("    address: %q\n", o.publicWeb()))
	b.WriteString("  traefik:\n")
	b.WriteString(fmt.Sprintf("    address: %q\n", "127.0.0.1:8081"))
	b.WriteString("\nproviders:\n")
	b.WriteString("  file:\n")
	b.WriteString(fmt.Sprintf("    filename: %q\n", "/etc/traefik/dynamic/orca.yml"))
	b.WriteString("    watch: true\n")
	b.WriteString("\nlog:\n")
	b.WriteString("  level: INFO\n")
	b.WriteString("  format: json\n")
	b.WriteString("\naccessLog:\n")
	b.WriteString("  format: json\n")
	return b.String()
}
