package httpapi

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/dtoro/oikos/internal/db"
	"github.com/dtoro/oikos/internal/db/sqlcgen"
	"github.com/dtoro/oikos/internal/domain"
	"github.com/dtoro/oikos/internal/httpapi/gen"
	"github.com/dtoro/oikos/internal/observability"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"
	"golang.org/x/crypto/ssh"
)

var (
	_sshUser string
	_sshKey  []byte
)

// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes").
// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool`
// field made the approved pct_create execution fail to parse *after* the
// operator had already approved it — the container was never created and the
// operator saw "queued" with no result. This type tolerates the common shapes.
type flexBool bool

func (b *flexBool) UnmarshalJSON(data []byte) error {
	s := strings.TrimSpace(strings.Trim(string(data), `"`))
	switch strings.ToLower(s) {
	case "true", "1", "yes", "on":
		*b = true
	case "false", "0", "no", "off", "", "null":
		*b = false
	default:
		return fmt.Errorf("cannot parse %q as bool", s)
	}
	return nil
}

func initSSH() {
	if _sshUser == "" {
		_sshUser = os.Getenv("OIKOS_SSH_USER")
		if _sshUser == "" {
			_sshUser = "root"
		}
	}
	if len(_sshKey) == 0 {
		keyPath := os.Getenv("OIKOS_SSH_KEY_PATH")
		if keyPath == "" {
			keyPath = "/etc/oikos/ssh_key"
		}
		var err error
		_sshKey, err = os.ReadFile(keyPath)
		if err != nil {
			slog.Warn("httpapi ssh: cannot read key", "path", keyPath, "error", err)
		}
	}
}

// sshExecTimeout bounds how long a single remote command may run. Without
// this, a hung remote command (e.g. a piped install script stuck retrying
// DNS against a misconfigured gateway) blocks the executing goroutine
// forever: the execution never leaves 'approved'/'running', the operator
// sees an unkillable spinner, and get_execution_status has nothing new to
// report. Generous enough for a real apt/docker install; not infinite.
const sshExecTimeout = 10 * time.Minute

func sshExec(ctx context.Context, host, user, command string) (string, error) {
	initSSH()
	if len(_sshKey) == 0 {
		return "", fmt.Errorf("no SSH key available")
	}
	if user == "" {
		user = _sshUser
	}

	addr := host + ":22"
	signer, err := ssh.ParsePrivateKey(_sshKey)
	if err != nil {
		return "", fmt.Errorf("parse key: %w", err)
	}

	cfg := &ssh.ClientConfig{
		User:            user,
		Auth:            []ssh.AuthMethod{ssh.PublicKeys(signer)},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
		Timeout:         10 * time.Second,
	}

	client, err := ssh.Dial("tcp", addr, cfg)
	if err != nil {
		return "", fmt.Errorf("dial %s: %w", host, err)
	}
	defer client.Close()

	session, err := client.NewSession()
	if err != nil {
		return "", fmt.Errorf("session: %w", err)
	}
	defer session.Close()

	type result struct {
		out []byte
		err error
	}
	done := make(chan result, 1)
	go func() {
		out, err := session.CombinedOutput(command)
		done <- result{out, err}
	}()

	select {
	case r := <-done:
		text := strings.TrimSpace(string(r.out))
		// A non-zero exit MUST surface as an error. The previous guard only
		// errored when there was no output, so a `pct create` that printed
		// "CT 132 already exists" and exited non-zero was reported as
		// success — the execution was marked completed though nothing was
		// provisioned.
		if r.err != nil {
			if text != "" {
				return text, fmt.Errorf("%w: %s", r.err, text)
			}
			return text, fmt.Errorf("exec: %w", r.err)
		}
		return text, nil
	case <-time.After(sshExecTimeout):
		// Close the session/client to hang up the remote side; the
		// goroutine above will eventually exit once that unblocks
		// CombinedOutput, but we don't wait for it — the caller needs an
		// answer now, not an indefinite hang.
		session.Close()
		client.Close()
		return "", fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
	case <-ctx.Done():
		session.Close()
		client.Close()
		return "", ctx.Err()
	}
}

func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {
	var attrs string
	err := pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs)
	if err != nil {
		return "", "", fmt.Errorf("entity not found: %s", entitySlug)
	}

	var m map[string]interface{}
	if err := json.Unmarshal([]byte(attrs), &m); err != nil {
		return "", "", fmt.Errorf("parse attributes: %w", err)
	}

	sshUser := _sshUser
	if sshUser == "" {
		sshUser = "root"
	}

	if ip, ok := m["lan_ip"].(string); ok && ip != "" {
		return ip, sshUser, nil
	}
	if mesh, ok := m["mesh"].(map[string]interface{}); ok {
		for _, proto := range []string{"netbird", "tailscale"} {
			if p, ok := mesh[proto].(map[string]interface{}); ok {
				if ip, ok := p["ip"].(string); ok && ip != "" {
					return ip, sshUser, nil
				}
			}
		}
	}
	return "", "", fmt.Errorf("no IP found for %s", entitySlug)
}

// resolveRunTarget mirrors internal/mcp.resolveExecTarget for the approved-
// execution side: any target slug (host: or lxc:) resolves to the SSH
// endpoint that runs the command plus a wrap function that turns a plain
// shell command into what actually needs to be sent — identity for a host,
// `pct exec <pve_id>` for an LXC. Kept as a small duplicate rather than a
// cross-package import to avoid coupling httpapi to mcp for one helper.
func resolveRunTarget(ctx context.Context, pool *db.Pool, targetSlug string) (host, user string, wrap func(string) string, err error) {
	if strings.HasPrefix(targetSlug, "host:") {
		host, user, err = resolveHostSSH(ctx, pool, targetSlug)
		return host, user, func(cmd string) string { return cmd }, err
	}
	if strings.HasPrefix(targetSlug, "lxc:") {
		var pveID, hostAttr string
		// COALESCE the host column: many older LXC entities (seeded from
		// inventory, not provisioned by pct_create) have pve_id but no host
		// attribute at all. Scanning a SQL NULL into a plain string errors
		// the whole row, wrongly reporting "missing pve_id" even when it was
		// present — COALESCE avoids the NULL, "" is handled below.
		if qerr := pool.QueryRow(ctx, "SELECT attributes->>'pve_id', COALESCE(attributes->>'host', '') FROM entities WHERE slug = $1", targetSlug).Scan(&pveID, &hostAttr); qerr != nil || pveID == "" {
			return "", "", nil, fmt.Errorf("LXC not found or missing pve_id: %s", targetSlug)
		}
		hostSlug := hostAttr
		if hostSlug == "" {
			hostSlug = "hubris"
		}
		if !strings.HasPrefix(hostSlug, "host:") {
			hostSlug = "host:" + hostSlug
		}
		host, user, err = resolveHostSSH(ctx, pool, hostSlug)
		id := pveID
		return host, user, func(cmd string) string {
			b64 := base64.StdEncoding.EncodeToString([]byte(cmd))
			return fmt.Sprintf("pct exec %s -- bash -c 'echo %s | base64 -d | bash'", id, b64)
		}, err
	}
	return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
}

// executeApprovedAction runs a gated action after operator approval.
// Runs in a background goroutine to not block the HTTP response.
// emitExecutionEvent records an execution lifecycle event for SSE fan-out so
// the control room can watch approved actions run to completion live.
func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) {
	severity := "info"
	if status == "failed" {
		severity = "warning"
	}
	_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
	if status == "completed" || status == "failed" || status == "cancelled" {
		closePlanStepForExecution(ctx, pool, execID, status)
	}
}

// closePlanStepForExecution auto-closes a task plan step whose linked execution
// just reached a terminal state, so the task board advances even if the agent
// doesn't call update_plan_step itself (belt and suspenders — the agent links
// the step to the execution when it starts it; the api finishes it here). Emits
// plan.step.finished correlated to the step's session. No-op for the vast
// majority of executions, which aren't plan steps.
func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) {
	stepStatus := "done"
	if execStatus == "failed" || execStatus == "cancelled" {
		stepStatus = "failed"
	}
	var stepID, sessionID string
	var seq int
	if err := pool.QueryRow(ctx, `
		UPDATE session_plan_steps SET status = $2, finished_at = now()
		WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped')
		RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil {
		return // no matching open step
	}
	_ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID,
		map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()})
}

func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
	slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)

	host, user, wrap, err := resolveRunTarget(ctx, pool, targetSlug)
	if err != nil {
		slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
		pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
			execID, jsonErr("%s", err.Error()))
		emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
		return
	}

	idx := strings.Index(actionStr, ":")
	if idx < 0 {
		slog.Error("httpapi: malformed action string (no colon)", "action", actionStr)
		return
	}
	action, params := actionStr[:idx], actionStr[idx+1:]

	startedAt := time.Now()
	var output, cmd string

	switch action {
	case "systemctl":
		svc := strings.TrimPrefix(targetSlug, "lxc:")
		switch {
		case strings.HasPrefix(params, "enable:"):
			svc = strings.TrimPrefix(params, "enable:")
			cmd = fmt.Sprintf("systemctl enable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
		case strings.HasPrefix(params, "disable:"):
			svc = strings.TrimPrefix(params, "disable:")
			cmd = fmt.Sprintf("systemctl disable %s --now 2>&1; sleep 1; systemctl is-active %s", svc, svc)
		default:
			cmd = fmt.Sprintf("systemctl %s %s 2>&1", params, svc)
		}
		output, err = sshExec(ctx, host, user, cmd)

	case "apt_upgrade":
		svc := strings.TrimPrefix(targetSlug, "lxc:")
		cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc)
		output, err = sshExec(ctx, host, user, cmd)

	case "pct_create":
		var cfg struct {
			VMID        int      `json:"vmid"`
			Hostname    string   `json:"hostname"`
			Cores       int      `json:"cores"`
			Memory      int      `json:"memory"`
			DiskGB      int      `json:"disk_gb"`
			IP          string   `json:"ip"`
			GW          string   `json:"gw"`
			Bridge      string   `json:"bridge"` // e.g. vmbr0/vmbr1 — which bridge actually reaches the target subnet on this host varies per host, don't assume vmbr0
			Storage     string   `json:"storage"`
			Template    string   `json:"template"`
			Privileged  flexBool `json:"privileged"`
			Nesting     flexBool `json:"nesting"`
			Mounts      []string `json:"mounts"`
			Nameserver  string   `json:"nameserver"`
			Searchdomain string  `json:"searchdomain"`
			// No services/post_install here anymore — pct_create is atomic
			// (create + start + register only). Installing packages and
			// running setup scripts is the agent's job via follow-up `run`
			// calls against lxc:<hostname>, so each step is individually
			// observable and recoverable instead of one opaque multi-minute
			// black box. See the comment above the removed post-create block.
		}
		if err := json.Unmarshal([]byte(params), &cfg); err != nil {
			slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
			pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
				execID, jsonErr("invalid pct_create params: %v", err))
			emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
			return
		}
		// Only hostname is required. vmid is optional — when 0 (or later found
		// to collide) the VMID guard below assigns a free cluster id.
		if cfg.Hostname == "" {
			pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
				execID, `{"error":"pct_create: hostname is required"}`)
			emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"})
			return
		}
		if cfg.Cores == 0 {
			cfg.Cores = 1
		}
		if cfg.Memory == 0 {
			cfg.Memory = 512
		}
		if cfg.DiskGB == 0 {
			cfg.DiskGB = 8
		}
		if cfg.Storage == "" {
			cfg.Storage = "local-lvm"
		}
		if cfg.GW == "" {
			cfg.GW = "192.168.8.2"
		}
		if cfg.Nameserver == "" {
			cfg.Nameserver = "192.168.8.2"
		}
		if cfg.Searchdomain == "" {
			cfg.Searchdomain = "hubris.network"
		}
		// Template pre-flight: resolve against what the host actually has
		// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
		// `pct` error when that exact file isn't present. List the cache, then
		// either validate the requested template or auto-pick the newest
		// debian one; on miss, fail early with the available list so the
		// operator/agent can retry with a real name.
		cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true")
		available := []string{}
		for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") {
			if l = strings.TrimSpace(l); l != "" {
				available = append(available, l)
			}
		}
		if tplErr != nil {
			pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
				execID, jsonErr("list templates on %s: %s", targetSlug, tplErr.Error()))
			emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()})
			return
		}
		cfg.Template = resolveTemplate(cfg.Template, available)
		if cfg.Template == "" {
			msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available)
			pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
				execID, jsonErr("%s", msg))
			emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
			return
		}

		// VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's
		// guess (e.g. 132) can collide with a container on another node — pct
		// create then fails with "CT N already exists on node X". Fetch the set
		// of in-use VMIDs across the cluster; if the requested id is taken (or
		// absent), fall back to the cluster's next free id so provisioning
		// still succeeds instead of dead-ending on the operator's approval.
		usedRaw, _ := sshExec(ctx, host, user, `pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`)
		used := map[int]bool{}
		for _, l := range strings.Fields(usedRaw) {
			if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
				used[n] = true
			}
		}
		if cfg.VMID == 0 || used[cfg.VMID] {
			nextRaw, nerr := sshExec(ctx, host, user, `pvesh get /cluster/nextid 2>/dev/null`)
			nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw))
			if nerr != nil || cerr != nil || nextID == 0 {
				msg := fmt.Sprintf("VMID %d is already in use on the cluster and could not resolve a free id", cfg.VMID)
				pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
					execID, jsonErr("%s", msg))
				emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
				return
			}
			slog.Info("httpapi: pct_create VMID reassigned", "requested", cfg.VMID, "assigned", nextID)
			cfg.VMID = nextID
		}

		privFlag := "--unprivileged 1"
		if cfg.Privileged {
			privFlag = "--unprivileged 0"
		}

		nestingFlag := ""
		features := []string{}
		if cfg.Nesting {
			features = append(features, "nesting=1")
		}
		if cfg.Privileged {
			features = append(features, "keyctl=1")
		}
		if len(features) > 0 {
			nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ","))
		}

		if cfg.Bridge == "" {
			cfg.Bridge = "vmbr0"
		}

		// net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox
		// rejects a gateway alongside ip=dhcp, so only add gw for a static IP.
		net0 := "name=eth0,bridge=" + cfg.Bridge + ","
		isStatic := cfg.IP != "" && !strings.EqualFold(cfg.IP, "dhcp")
		if !isStatic {
			net0 += "ip=dhcp"
		} else {
			net0 += "ip=" + cfg.IP
			if cfg.GW != "" {
				net0 += ",gw=" + cfg.GW
			}
		}

		// Pre-flight: for a static config, ping the gateway from the target
		// HOST, on the SPECIFIC BRIDGE being requested, before spending 5+
		// minutes creating the container. This is the check that would have
		// caught the real TypeType failure immediately instead of after a
		// full provision attempt.
		//
		// Binding to the bridge (`ping -I <bridge>`) matters and was found
		// live: a plain unqualified `ping <gw>` from the host can succeed via
		// the host's own routing table (multiple routes, possibly through an
		// upstream router) even when the *container* — which only gets a
		// naive on-link default route via its bridge's veth — can never ARP
		// that gateway at all. Confirmed on `strong`: bare `ping 192.168.8.2`
		// succeeded (via the host's default route), but a container actually
		// attached to vmbr0 showed 100% packet loss trying to reach the same
		// address, because vmbr0 doesn't carry that subnet's L2 segment.
		// Binding to the bridge interface reproduces what the container will
		// actually experience, not what the host's broader routing table can
		// reach.
		if isStatic && cfg.GW != "" {
			pingOut, pingErr := sshExec(ctx, host, user, fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", cfg.Bridge, cfg.GW))
			if pingErr != nil || !gatewayPreflightPassed(pingOut) {
				msg := fmt.Sprintf(
					"gateway %s is not reachable from %s on bridge %s — this almost always means the bridge doesn't carry that subnet on this host (each bridge only reaches the network it's physically wired to). "+
						"Do not retry with a different gateway guess in the same subnet: find an existing LXC on this host with an IP in the same /28 and copy its exact bridge+gateway, or use DHCP instead.",
					cfg.GW, targetSlug, cfg.Bridge)
				pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
					execID, jsonErr("%s", msg))
				emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
				return
			}
		}

		templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template)
		createCmd := fmt.Sprintf(
			"pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1",
			cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory,
			cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag)

		if cfg.Nameserver != "" {
			createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver)
		}
		if cfg.Searchdomain != "" {
			createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain)
		}

		// Add mount points
		for i, mp := range cfg.Mounts {
			if i < 10 { // pct supports up to mp9
				createCmd += fmt.Sprintf(" --mp%d %s", i, mp)
			}
		}

		slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
		output, err = sshExec(ctx, host, user, createCmd)

		// pct_create is now DELIBERATELY ATOMIC: create + start + register,
		// nothing else. It used to also run apt installs and a post_install
		// script inline as one black-box multi-minute SSH call — the agent
		// got back a single opaque success/fail for the whole thing with no
		// way to see (or fix) which step actually broke. That's the opposite
		// of what makes an agent able to recover from errors.
		//
		// Installing packages, running post_install, and verifying the
		// service now happen as the agent's OWN follow-up `run` calls against
		// the new lxc:<hostname> target — each one is synchronous (in an
		// active assent window) or individually gated, so the agent observes
		// every step's real output and can diagnose + retry the exact thing
		// that failed instead of re-doing the whole container. See SOUL.md
		// "After pct_create: you drive the install" and provisionScript's
		// surviving role (DNS self-heal) is now something the agent invokes
		// itself via `run`, not something baked into this handler.
		//
		// cfg.Services/cfg.PostInstall are intentionally no longer read here.

		// On success, register the entity in the DB with proper relationships
		if err == nil {
			slug := "lxc:" + cfg.Hostname
			var lxcID uuid.UUID
			lxcID, _ = uuid.NewV7()
			attrs := map[string]any{
				"pve_id": fmt.Sprintf("%d", cfg.VMID),
				"host":   strings.TrimPrefix(targetSlug, "host:"),
				"ip":     cfg.IP,
			}
			attrsJSON, _ := json.Marshal(attrs)
			_, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at)
				VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON)
			if insErr != nil {
				slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug)
			}

			// Create hosts relationship: Proxmox host → LXC
			var hostID uuid.UUID
			if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil {
				_, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
					VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID)
				if relErr != nil {
					slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug)
				}
			}

			// Create entity_status row for health tracking
			pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at)
				VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID)

			emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{
				"lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug,
			})

			slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug)
		}

	case "run":
		// The general gated primitive: arbitrary shell against any host or
		// LXC, approved and classified by internal/policy.ClassifyCommand at
		// request time (see mcp/server.go's "run" tool). No fixed action
		// enum — new capability doesn't require new Go code here.
		var cfg struct {
			Command string `json:"command"`
			Purpose string `json:"purpose"`
		}
		if perr := json.Unmarshal([]byte(params), &cfg); perr != nil {
			pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
				execID, jsonErr("invalid run params: %v", perr))
			emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": perr.Error()})
			return
		}
		cmd = wrap(cfg.Command)
		output, err = sshExec(ctx, host, user, cmd)

	default:
		slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID)
		pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
			execID, jsonErr("unknown action: %s", action))
		return
	}

	durationMs := int(time.Since(startedAt).Milliseconds())
	status := "completed"
	verified := true
	// Build result via json.Marshal, not string interpolation. Command output
	// (apt/pct) contains quotes, backslashes and control chars; the old
	// fmt.Sprintf only escaped "\n", producing invalid JSON that failed the
	// ::jsonb cast — so this UPDATE was silently discarded and the execution
	// was stuck at "approved" forever even though provisioning succeeded.
	resMap := map[string]any{"output": output}
	if err != nil {
		resMap["error"] = err.Error()
		status = "failed"
		verified = false
	}
	resultJSON, _ := json.Marshal(resMap)

	if _, uerr := pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
		execID, status, resultJSON, durationMs, verified, startedAt, time.Now()); uerr != nil {
		slog.Error("httpapi: finalize execution status", "error", uerr, "execution_id", execID, "intended_status", status)
	}

	emitExecutionEvent(ctx, pool, execID, status, map[string]any{
		"action": action, "target": targetSlug, "duration_ms": durationMs,
	})

	slog.Info("httpapi: approved action executed",
		"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
}

// jsonErr builds a valid {"error": "..."} JSON payload for an execution's
// result column. Always use this instead of fmt.Sprintf'ing JSON by hand —
// error text and command output routinely contain quotes/backslashes that
// break a hand-built string and fail the ::jsonb cast.
func jsonErr(format string, args ...any) []byte {
	b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
	return b
}

// resolveTemplate maps a requested template name to one actually present in
// the host's template cache. Exact match wins; a bare distro hint (e.g.
// "debian-13" or "debian") matches by prefix; empty picks the newest debian
// (falling back to any) template available. Returns "" when nothing fits.
// gatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL markers
// from the pct_create gateway pre-flight check. Pulled out as its own
// function (rather than an inline strings.Contains at the call site) so it's
// unit-testable: a prior version checked for "REACHABLE", which is a
// substring of "UNREACHABLE" — the check could never actually fail, and it
// took a live deployment to notice. Exact-match markers plus a test make
// that specific bug class structurally unable to recur silently.
func gatewayPreflightPassed(out string) bool {
	return strings.TrimSpace(out) == "PREFLIGHT_OK"
}

func resolveTemplate(requested string, available []string) string {
	if len(available) == 0 {
		return ""
	}
	if requested != "" {
		for _, a := range available {
			if a == requested {
				return a
			}
		}
		for _, a := range available {
			if strings.HasPrefix(a, requested) {
				return a
			}
		}
	}
	// Auto-pick: prefer debian, then the lexically-greatest (newest version).
	best := ""
	for _, a := range available {
		if strings.Contains(a, "debian") && a > best {
			best = a
		}
	}
	if best != "" {
		return best
	}
	for _, a := range available {
		if a > best {
			best = a
		}
	}
	return best
}

// ─── Checks ────────────────────────────────────────────────────────────

func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
	limit := clampLimit(req.Params.Limit)
	rows, err := s.pool.Query(ctx, `
		SELECT cd.entity_id, e.slug, cd.kind,
		       COALESCE(te.slug, '') AS target_slug, cd.target_type,
		       cd.config, cd.interval_s, cd.timeout_s, cd.zone, cd.enabled,
		       e.version
		FROM check_defs cd
		JOIN entities e ON e.id = cd.entity_id
		LEFT JOIN entities te ON te.id = cd.target_id
		WHERE ($1::text IS NULL OR cd.kind = $1)
		  AND ($2::text IS NULL OR te.slug = $2)
		  AND ($3::bool IS NULL OR cd.enabled = $3)
		  AND ($4::text IS NULL OR e.slug > $4)
		ORDER BY e.slug
		LIMIT $5`,
		req.Params.Kind, req.Params.Target, req.Params.Enabled, req.Params.Cursor, limit+1)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Check{}
	for rows.Next() {
		var c gen.Check
		var targetSlug string
		var configBytes []byte
		if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType,
			&configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version); err != nil {
			return nil, err
		}
		if targetSlug != "" {
			c.Target = &targetSlug
		}
		var config map[string]any
		if len(configBytes) > 0 && json.Unmarshal(configBytes, &config) == nil && len(config) > 0 {
			c.Config = &config
		}
		items = append(items, c)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	var next *string
	if len(items) > limit {
		items = items[:limit]
		next = &items[len(items)-1].Slug
	}
	if items == nil {
		items = []gen.Check{}
	}
	return gen.ListChecks200JSONResponse{Items: items, NextCursor: next}, nil
}

func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObject) (gen.CreateCheckResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := uuid.NewV7()
	if err != nil {
		return nil, err
	}

	slug := req.Body.Slug
	if slug == "" {
		slug = "check:" + string(req.Body.Kind) + ":" + uuid.New().String()[:8]
	}

	// Resolve target if provided.
	var targetID *uuid.UUID
	if req.Body.Target != nil && *req.Body.Target != "" {
		tid, rerr := s.resolveEntityID(ctx, *req.Body.Target)
		if rerr != nil {
			return nil, rerr
		}
		targetID = &tid
	}

	intervalS := int32(300)
	if req.Body.IntervalS != nil {
		intervalS = int32(*req.Body.IntervalS)
	}
	timeoutS := int32(30)
	if req.Body.TimeoutS != nil {
		timeoutS = int32(*req.Body.TimeoutS)
	}
	enabled := true
	if req.Body.Enabled != nil {
		enabled = *req.Body.Enabled
	}

	configJSON := []byte("{}")
	if req.Body.Config != nil {
		configJSON, _ = json.Marshal(req.Body.Config)
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	q := sqlcgen.New(tx)

	// Create the entity row (checks are entities).
	entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
		ID:         id,
		Slug:       slug,
		Type:       "check",
		Name:       slug,
		Attributes: []byte("{}"),
	})
	if err != nil {
		if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
			return nil, fmt.Errorf("%w: check %q already exists", domain.ErrAlreadyExists, slug)
		}
		return nil, err
	}

	if err := q.InsertCheckDef(ctx, sqlcgen.InsertCheckDefParams{
		EntityID:   id,
		TargetID:   targetID,
		TargetType: req.Body.TargetType,
		Kind:       string(req.Body.Kind),
		Config:     configJSON,
		IntervalS:  intervalS,
		TimeoutS:   timeoutS,
		Zone:       req.Body.Zone,
		Enabled:    enabled,
	}); err != nil {
		return nil, err
	}

	// Build response Check.
	check := gen.Check{
		Id:         id,
		Slug:       entity.Slug,
		Kind:       gen.CheckKind(req.Body.Kind),
		IntervalS:  int(intervalS),
		TimeoutS:   int(timeoutS),
		Enabled:    enabled,
		TargetType: req.Body.TargetType,
		Zone:       req.Body.Zone,
		Version:    int(entity.Version),
	}
	if req.Body.Config != nil {
		check.Config = req.Body.Config
	}
	if targetID != nil && req.Body.Target != nil {
		check.Target = req.Body.Target
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
		&id, "POST", "/api/v1/checks", "",
		map[string]any{"kind": req.Body.Kind, "slug": slug}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.CreateCheck201JSONResponse(check), nil
}

func (s *Server) PatchCheck(ctx context.Context, req gen.PatchCheckRequestObject) (gen.PatchCheckResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	// Parse If-Match
	ifMatch := strings.Trim(req.Params.IfMatch, `"`)
	expectedVersion, err := parseIntIfMatch(ifMatch)
	if err != nil {
		return nil, err
	}
	_ = expectedVersion // check_defs don't track version via If-Match today, but we validate the header is present

	if ifMatch == "" {
		return nil, fmt.Errorf("%w: invalid If-Match header", domain.ErrInvalidInput)
	}

	// Get current check def
	current, err := sqlcgen.New(s.pool).GetCheckDef(ctx, id)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("%w: check %s", domain.ErrNotFound, req.Id)
		}
		return nil, err
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	// Apply patch.
	if req.Body.Config != nil {
		current.Config, _ = json.Marshal(req.Body.Config)
	}
	if req.Body.IntervalS != nil {
		current.IntervalS = int32(*req.Body.IntervalS)
	}
	if req.Body.TimeoutS != nil {
		current.TimeoutS = int32(*req.Body.TimeoutS)
	}
	if req.Body.Enabled != nil {
		current.Enabled = *req.Body.Enabled
	}

	if err := sqlcgen.New(tx).UpdateCheckDef(ctx, sqlcgen.UpdateCheckDefParams{
		EntityID:   id,
		Kind:       current.Kind,
		Config:     current.Config,
		IntervalS:  current.IntervalS,
		TimeoutS:   current.TimeoutS,
		TargetID:   current.TargetID,
		TargetType: current.TargetType,
		Zone:       current.Zone,
		Enabled:    current.Enabled,
	}); err != nil {
		return nil, err
	}

	// Re-read to get updated timestamp.
	updated, err := sqlcgen.New(tx).GetCheckDef(ctx, id)
	if err != nil {
		return nil, err
	}

	check := checkDefToGen(updated)

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
		&id, "PATCH", "/api/v1/checks/"+req.Id, "",
		map[string]any{"enabled": updated.Enabled}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.PatchCheck200JSONResponse(check), nil
}

func checkDefToGen(cd sqlcgen.CheckDef) gen.Check {
	c := gen.Check{
		Id:         cd.EntityID,
		Kind:       gen.CheckKind(cd.Kind),
		IntervalS:  int(cd.IntervalS),
		TimeoutS:   int(cd.TimeoutS),
		Enabled:    cd.Enabled,
		TargetType: cd.TargetType,
		Zone:       cd.Zone,
	}
	var config map[string]any
	if len(cd.Config) > 0 && json.Unmarshal(cd.Config, &config) == nil && len(config) > 0 {
		c.Config = &config
	}
	return c
}

// parseIntIfMatch parses an integer from a raw If-Match header value (with quotes stripped).
func parseIntIfMatch(s string) (int, error) {
	if s == "" {
		return 0, fmt.Errorf("empty version")
	}
	var v int
	for _, c := range s {
		if c < '0' || c > '9' {
			return 0, fmt.Errorf("invalid version: %q", s)
		}
		v = v*10 + int(c-'0')
	}
	return v, nil
}

// ─── Classifications ───────────────────────────────────────────────────

func (s *Server) ListClassifications(ctx context.Context, req gen.ListClassificationsRequestObject) (gen.ListClassificationsResponseObject, error) {
	limit := clampLimit(req.Params.Limit)
	var route *string
	if req.Params.Route != nil {
		r := string(*req.Params.Route)
		route = &r
	}
	rows, err := s.pool.Query(ctx, `
		SELECT c.entity_id, c.signal_entity_id, c.target_entity_id, c.action,
		       c.recommended_action, c.risk_class, c.route, c.blast_radius,
		       c.pattern_confidence, c.skill_id, c.autonomy_check, c.reasoning,
		       c.correlation_id, c.created_at,
		       e.slug, COALESCE(se.slug, '') AS signal_slug, COALESCE(te.slug, '') AS target_slug
		FROM classifications c
		LEFT JOIN entities e ON e.id = c.entity_id
		LEFT JOIN entities se ON se.id = c.signal_entity_id
		LEFT JOIN entities te ON te.id = c.target_entity_id
		WHERE ($1::text IS NULL OR c.route = $1)
		  AND ($2::text IS NULL OR e.slug > $2)
		ORDER BY e.slug
		LIMIT $3`,
		route, req.Params.Cursor, limit+1)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Classification{}
	for rows.Next() {
		var cls gen.Classification
		var recActionJSON []byte
		var reasoningJSON []byte
		var blastRadius []uuid.UUID
		var signalSlug, targetSlug string
		if err := rows.Scan(&cls.Id, &cls.SignalId, &targetSlug, &cls.Action,
			&recActionJSON, &cls.RiskClass, &cls.Route, &blastRadius,
			&cls.PatternConfidence, &cls.SkillId, &cls.AutonomyCheck, &reasoningJSON,
			&cls.CorrelationId, &cls.CreatedAt,
			&cls.Target, &signalSlug, &targetSlug); err != nil {
			return nil, err
		}
		if targetSlug != "" {
			cls.Target = &targetSlug
		}
		var reasoning map[string]any
		if json.Unmarshal(reasoningJSON, &reasoning) == nil {
			cls.Reasoning = reasoning
		}
		if len(blastRadius) > 0 {
			br := make([]string, len(blastRadius))
			for i, id := range blastRadius {
				br[i] = id.String()
			}
			cls.BlastRadius = &br
		}
		items = append(items, cls)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	var next *string
	if len(items) > limit {
		items = items[:limit]
		if items[len(items)-1].Target != nil {
			next = items[len(items)-1].Target
		}
	}
	if items == nil {
		items = []gen.Classification{}
	}
	return gen.ListClassifications200JSONResponse{Items: items, NextCursor: next}, nil
}

// ─── Executions ────────────────────────────────────────────────────────

func (s *Server) ListExecutions(ctx context.Context, req gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) {
	limit := clampLimit(req.Params.Limit)
	rows, err := s.pool.Query(ctx, `
		SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
		       e.target_entity_id, e.action, e.risk_class,
		       e.approval_id::text, e.agent_id::text, e.skill_id::text,
		       e.skill_version, e.status, e.result, e.duration_ms,
		       e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
		       te.slug
		FROM executions e
		JOIN entities te ON te.id = e.target_entity_id
		WHERE ($1::text IS NULL OR e.status = $1)
		  AND ($2::text IS NULL OR te.slug > $2)
		ORDER BY te.slug
		LIMIT $3`,
		req.Params.Status, req.Params.Cursor, limit+1)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Execution{}
	for rows.Next() {
		var exec gen.Execution
		var resultBytes []byte
		var targetSlug string
		if err := rows.Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
			&exec.Target, &exec.Action, &exec.RiskClass,
			&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
			&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
			&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
			&exec.CreatedAt, &targetSlug); err != nil {
			return nil, err
		}
		var result map[string]any
		if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
			exec.Result = &result
		}
		// Target is stored as UUID, but we surface the slug
		exec.Slug = targetSlug
		items = append(items, exec)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	var next *string
	if len(items) > limit {
		items = items[:limit]
		next = &items[len(items)-1].Slug
	}
	if items == nil {
		items = []gen.Execution{}
	}
	return gen.ListExecutions200JSONResponse{Items: items, NextCursor: next}, nil
}

func (s *Server) GetExecution(ctx context.Context, req gen.GetExecutionRequestObject) (gen.GetExecutionResponseObject, error) {
	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	var exec gen.Execution
	var resultBytes []byte
	var targetSlug string
	err = s.pool.QueryRow(ctx, `
		SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
		       e.target_entity_id, e.action, e.risk_class,
		       e.approval_id::text, e.agent_id::text, e.skill_id::text,
		       e.skill_version, e.status, e.result, e.duration_ms,
		       e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
		       te.slug
		FROM executions e
		JOIN entities te ON te.id = e.target_entity_id
		WHERE e.entity_id = $1`, id).
		Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
			&exec.Target, &exec.Action, &exec.RiskClass,
			&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
			&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
			&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
			&exec.CreatedAt, &targetSlug)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
		}
		return nil, err
	}
	var result map[string]any
	if len(resultBytes) > 0 && json.Unmarshal(resultBytes, &result) == nil {
		exec.Result = &result
	}
	exec.Slug = targetSlug
	return gen.GetExecution200JSONResponse(exec), nil
}

func (s *Server) RequestExecution(ctx context.Context, req gen.RequestExecutionRequestObject) (gen.RequestExecutionResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := uuid.NewV7()
	if err != nil {
		return nil, err
	}

	targetID, err := s.resolveEntityID(ctx, req.Body.Target)
	if err != nil {
		return nil, err
	}

	correlationID := uuid.New().String()

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	q := sqlcgen.New(tx)

	// Full UUID, not a truncated prefix — an 8-char prefix of a UUIDv7
	// collides for real under back-to-back requests since the leading bytes
	// encode a millisecond timestamp (observed live via the MCP run tool).
	execSlug := "exec:" + id.String()
	if _, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
		ID:         id,
		Slug:       execSlug,
		Type:       "execution",
		Name:       req.Body.Action + " on " + req.Body.Target,
		Attributes: []byte("{}"),
	}); err != nil {
		return nil, err
	}

	if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
		EntityID:       id,
		TargetEntityID: &targetID,
		Action:         req.Body.Action,
		RiskClass:      "unclassified", // will be classified by classifier
		CorrelationID:  correlationID,
	}); err != nil {
		return nil, err
	}

	// Re-read to get the full record.
	var exec gen.Execution
	var resultBytes []byte
	var targetSlug string
	err = tx.QueryRow(ctx, `
		SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
		       e.target_entity_id, e.action, e.risk_class,
		       e.approval_id::text, e.agent_id::text, e.skill_id::text,
		       e.skill_version, e.status, e.result, e.duration_ms,
		       e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
		       te.slug
		FROM executions e
		JOIN entities te ON te.id = e.target_entity_id
		WHERE e.entity_id = $1`, id).
		Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
			&exec.Target, &exec.Action, &exec.RiskClass,
			&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
			&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
			&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
			&exec.CreatedAt, &targetSlug)
	if err != nil {
		return nil, err
	}
	exec.Slug = targetSlug

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
		&id, "POST", "/api/v1/executions", "",
		map[string]any{"action": req.Body.Action, "target": req.Body.Target}); auditErr != nil {
		return nil, auditErr
	}
	if eventErr := observability.Event(ctx, q, "execution.requested", &id,
		"info", "oikos-api", "",
		map[string]any{"action": req.Body.Action, "target": req.Body.Target}); eventErr != nil {
		return nil, eventErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.RequestExecution201JSONResponse(exec), nil
}

func (s *Server) CancelExecution(ctx context.Context, req gen.CancelExecutionRequestObject) (gen.CancelExecutionResponseObject, error) {
	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	q := sqlcgen.New(tx)
	if err := q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
		EntityID: id,
		Status:   "cancelled",
	}); err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("%w: execution %s", domain.ErrNotFound, req.Id)
		}
		return nil, err
	}

	// Re-read.
	var exec gen.Execution
	var resultBytes []byte
	var targetSlug string
	err = tx.QueryRow(ctx, `
		SELECT e.entity_id, e.classification_id::text, e.signal_entity_id::text,
		       e.target_entity_id, e.action, e.risk_class,
		       e.approval_id::text, e.agent_id::text, e.skill_id::text,
		       e.skill_version, e.status, e.result, e.duration_ms,
		       e.verified, e.correlation_id, e.started_at, e.completed_at, e.created_at,
		       te.slug
		FROM executions e
		JOIN entities te ON te.id = e.target_entity_id
		WHERE e.entity_id = $1`, id).
		Scan(&exec.Id, &exec.ClassificationId, &exec.SignalId,
			&exec.Target, &exec.Action, &exec.RiskClass,
			&exec.ApprovalId, &exec.AgentId, &exec.SkillId,
			&exec.SkillVersion, &exec.Status, &resultBytes, &exec.DurationMs,
			&exec.Verified, &exec.CorrelationId, &exec.StartedAt, &exec.CompletedAt,
			&exec.CreatedAt, &targetSlug)
	if err != nil {
		return nil, err
	}
	exec.Slug = targetSlug

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, q, actorType, actor, "cancel",
		&id, "POST", "/api/v1/executions/"+req.Id+"/cancel", "",
		map[string]any{"status": "cancelled"}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.CancelExecution200JSONResponse(exec), nil
}

// ─── Approvals ─────────────────────────────────────────────────────────

func (s *Server) ListApprovals(ctx context.Context, req gen.ListApprovalsRequestObject) (gen.ListApprovalsResponseObject, error) {
	limit := clampLimit(req.Params.Limit)
	var status *string
	if req.Params.Status != nil {
		s := string(*req.Params.Status)
		status = &s
	}
	var kind *string
	if req.Params.Kind != nil {
		k := string(*req.Params.Kind)
		kind = &k
	}
	rows, err := s.pool.Query(ctx, `
		SELECT a.entity_id, a.action, a.risk_class, a.kind, a.payload,
		       a.status, a.expires_at, a.decided_at, a.decided_by::text,
		       a.created_at, e.slug
		FROM approvals a
		JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id)
		WHERE ($1::text IS NULL OR a.status = $1)
		  AND ($2::text IS NULL OR a.kind = $2)
		  AND ($3::text IS NULL OR e.slug > $3)
		ORDER BY e.slug
		LIMIT $4`,
		status, kind, req.Params.Cursor, limit+1)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Approval{}
	for rows.Next() {
		var a gen.Approval
		var payloadBytes []byte
		var decidedBy *string
		if err := rows.Scan(&a.Id, &a.Action, &a.RiskClass, &a.Kind, &payloadBytes,
			&a.Status, &a.ExpiresAt, &a.DecidedAt, &decidedBy,
			&a.CreatedAt, &a.Slug); err != nil {
			return nil, err
		}
		a.DecidedBy = decidedBy
		var payload map[string]any
		if len(payloadBytes) > 0 && json.Unmarshal(payloadBytes, &payload) == nil {
			a.Payload = &payload
		}
		items = append(items, a)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	var next *string
	if len(items) > limit {
		items = items[:limit]
		next = &items[len(items)-1].Slug
	}
	if items == nil {
		items = []gen.Approval{}
	}
	return gen.ListApprovals200JSONResponse{Items: items, NextCursor: next}, nil
}

func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalRequestObject) (gen.DecideApprovalResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	actorType, actor := actorInfo(ctx)

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	q := sqlcgen.New(tx)

	// Verify HMAC token if provided (single-use, S5).
	if req.Body.Token != nil && *req.Body.Token != "" {
		var tokenHash *string
		var apprStatus string
		var expiresAt time.Time
		err := tx.QueryRow(ctx,
			"SELECT token_hash, status, expires_at FROM approvals WHERE entity_id = $1",
			id).Scan(&tokenHash, &apprStatus, &expiresAt)
		if err != nil || tokenHash == nil {
			return nil, fmt.Errorf("%w: approval not found", domain.ErrNotFound)
		}
		if apprStatus != "pending" {
			return nil, fmt.Errorf("%w: approval already decided", domain.ErrInvalidTransition)
		}
		if expiresAt.Before(time.Now()) {
			return nil, fmt.Errorf("%w: approval token expired", domain.ErrInvalidTransition)
		}
		if *tokenHash != hashToken(*req.Body.Token) {
			return nil, fmt.Errorf("%w: invalid approval token", domain.ErrInvalidInput)
		}
	}

	// Map decision to status.
	var status string
	switch req.Body.Decision {
	case gen.Approve:
		status = "approved"
	case gen.Deny:
		status = "denied"
	case gen.Revoke:
		status = "revoked"
	default:
		return nil, fmt.Errorf("%w: invalid decision %q", domain.ErrInvalidInput, req.Body.Decision)
	}

	if err := q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
		EntityID: id,
		Status:   status,
	}); err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("%w: approval %s not found or already decided", domain.ErrNotFound, req.Id)
		}
		return nil, err
	}

	// Re-read approval.
	app, err := q.GetApprovalByID(ctx, id)
	if err != nil {
		return nil, err
	}

	approval := approvalToGen(app)

	if auditErr := observability.Audit(ctx, q, actorType, actor, "decide",
		&id, "POST", "/api/v1/approvals/"+req.Id+"/decision", "",
		map[string]any{"decision": status}); auditErr != nil {
		return nil, auditErr
	}

	// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
	if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
		map[string]any{"decision": status, "actor": actor}); evErr != nil {
		return nil, evErr
	}

	// On approve: execute the linked gated command.
	if status == "approved" {
		var execID, targetID uuid.UUID
		var actionStr, targetSlug, riskClass string
		err := tx.QueryRow(ctx, `
			SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class
			FROM executions e
			WHERE e.approval_id = $1 AND e.status = 'pending_approval'
			LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass)
		if err == nil {
			// Resolve target entity slug from targetID.
			_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)

			go executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
			// Status only — risk_class was set correctly at request time
			// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
			// a hardcoded 'config_mutation' here corrupted the audit ledger
			// for every other risk class, including destructive.
			_, _ = tx.Exec(ctx, `UPDATE executions SET status = 'approved' WHERE entity_id = $1`, execID)

			// Approving a plan step — by ANY route (this endpoint backs both
			// the chat Approve button and chat-assent) — opens/extends the
			// agent's assent window. This is the scope gate the Nomos
			// auto-continuation worker checks: with the window open, the
			// finished execution's result is fed back to the agent so it runs
			// the plan to completion. Without opening it here, approving via
			// the button (instead of typing "go ahead") would silently not
			// auto-continue.
			var agentID *uuid.UUID
			if qerr := tx.QueryRow(ctx, "SELECT agent_id FROM executions WHERE entity_id = $1", execID).Scan(&agentID); qerr == nil && agentID != nil {
				expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
				_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
					ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires)

				// Approving a DESTRUCTIVE step via the button is exactly as
				// explicit as a typed "I confirm" — the operator affirmatively
				// clicked Approve on a card that said DESTRUCTIVE. Open the
				// same short, target-scoped destructive window chat-assent's
				// typed-confirm path opens, for parity: a multi-step
				// destructive recovery (stop, then destroy) shouldn't need a
				// fresh confirmation per click any more than it needs one per
				// typed phrase.
				if riskClass == "destructive" && targetSlug != "" {
					dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339)
					_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
						ON CONFLICT (key) DO UPDATE SET value = $2`,
						"destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires)
				}
			}

			slog.Info("httpapi: approved execution queued",
				"execution_id", execID, "target", targetSlug, "action", actionStr)
		} else {
			slog.Warn("httpapi: no pending execution found for approval", "approval_id", id, "error", err)
		}
	} else {
		// Denied/revoked: reflect it on the linked execution too. Previously
		// only the approvals row changed, so the execution stayed
		// 'pending_approval' forever — any UI/poller reading execution
		// status (not approval status) never saw the decision.
		_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.DecideApproval200JSONResponse(approval), nil
}

func approvalToGen(a sqlcgen.Approval) gen.Approval {
	app := gen.Approval{
		Id:        a.EntityID,
		Action:    a.Action,
		RiskClass: a.RiskClass,
		Kind:      gen.ApprovalKind(a.Kind),
		Status:    gen.ApprovalStatus(a.Status),
		ExpiresAt: a.ExpiresAt,
		DecidedAt: a.DecidedAt,
		CreatedAt: a.CreatedAt,
	}
	if a.DecidedBy != nil {
		s := a.DecidedBy.String()
		app.DecidedBy = &s
	}
	var payload map[string]any
	if len(a.Payload) > 0 && json.Unmarshal(a.Payload, &payload) == nil && len(payload) > 0 {
		app.Payload = &payload
	}
	return app
}

// ─── Patterns ──────────────────────────────────────────────────────────

func (s *Server) ListPatterns(ctx context.Context, req gen.ListPatternsRequestObject) (gen.ListPatternsResponseObject, error) {
	limit := clampLimit(req.Params.Limit)
	rows, err := s.pool.Query(ctx, `
		SELECT p.entity_id, e.slug, p.applies_type, p.action, p.pattern, p.confidence,
		       p.evidence_count, p.success_count, p.failure_count, p.status,
		       p.quarantined, p.version, p.last_validated_at
		FROM patterns p
		JOIN entities e ON e.id = p.entity_id
		WHERE ($1::text IS NULL OR p.status = $1)
		  AND ($2::text IS NULL OR p.applies_type = $2)
		  AND ($3::text IS NULL OR p.action = $3)
		ORDER BY p.applies_type, p.action
		LIMIT $4`,
		req.Params.Status, req.Params.EntityType, req.Params.Action, limit+1)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Pattern{}
	for rows.Next() {
		var p gen.Pattern
		if err := rows.Scan(&p.Id, &p.Slug, &p.AppliesType, &p.Action, &p.Pattern,
			&p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount,
			&p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt); err != nil {
			return nil, err
		}
		items = append(items, p)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	if items == nil {
		items = []gen.Pattern{}
	}
	return gen.ListPatterns200JSONResponse{Items: items}, nil
}

func (s *Server) PatchPattern(ctx context.Context, req gen.PatchPatternRequestObject) (gen.PatchPatternResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	q := sqlcgen.New(tx)

	if req.Body.Status != nil {
		status := string(*req.Body.Status)
		if err := q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
			EntityID: id,
			Status:   status,
		}); err != nil {
			if err == pgx.ErrNoRows {
				return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id)
			}
			return nil, err
		}
	}

	if req.Body.Quarantined != nil {
		if err := q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
			EntityID:    id,
			Quarantined: *req.Body.Quarantined,
		}); err != nil {
			return nil, err
		}
	}

	// Re-read.
	var p gen.Pattern
	err = tx.QueryRow(ctx, `
		SELECT entity_id, applies_type, action, pattern, confidence,
		       evidence_count, success_count, failure_count, status,
		       quarantined, version, last_validated_at
		FROM patterns WHERE entity_id = $1`, id).
		Scan(&p.Id, &p.AppliesType, &p.Action, &p.Pattern,
			&p.Confidence, &p.EvidenceCount, &p.SuccessCount, &p.FailureCount,
			&p.Status, &p.Quarantined, &p.Version, &p.LastValidatedAt)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("%w: pattern %s", domain.ErrNotFound, req.Id)
		}
		return nil, err
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
		&id, "PATCH", "/api/v1/patterns/"+req.Id, "",
		map[string]any{"status": req.Body.Status, "quarantined": req.Body.Quarantined}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.PatchPattern200JSONResponse(p), nil
}

// ─── Skills ────────────────────────────────────────────────────────────

func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject) (gen.ListSkillsResponseObject, error) {
	limit := clampLimit(req.Params.Limit)
	rows, err := s.pool.Query(ctx, `
		SELECT s.entity_id, s.version, s.name, s.procedure, s.applies_type,
		       s.action, s.pattern_ids, s.status, s.success_rate,
		       s.changed_by::text, s.change_reason, s.last_used_at
		FROM skills s
		WHERE ($1::text IS NULL OR s.status = $1)
		  AND ($2::text IS NULL OR s.applies_type = $2)
		  AND ($3::text IS NULL OR s.action = $3)
		ORDER BY s.name, s.version DESC`,
		req.Params.Status, req.Params.AppliesTo, req.Params.Action)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	// Deduplicate to latest version per skill (the ORDER BY name, version DESC
	// means the first row per name is the latest).
	seen := map[string]bool{}
	items := []gen.Skill{}
	for rows.Next() {
		var s gen.Skill
		var procBytes []byte
		var patternIDs []uuid.UUID
		if err := rows.Scan(&s.Id, &s.Version, &s.Name, &procBytes, &s.AppliesType,
			&s.Action, &patternIDs, &s.Status, &s.SuccessRate,
			&s.ChangedBy, &s.ChangeReason, &s.LastUsedAt); err != nil {
			return nil, err
		}
		if seen[s.Id.String()] {
			continue
		}
		seen[s.Id.String()] = true
		if err := json.Unmarshal(procBytes, &s.Procedure); err != nil {
			slog.Warn("phase3: unmarshal skill procedure", "skill", s.Name, "error", err)
		}
		if len(patternIDs) > 0 {
			pids := make([]string, len(patternIDs))
			for i, pid := range patternIDs {
				pids[i] = pid.String()
			}
			s.PatternIds = &pids
		}
		items = append(items, s)
		if len(items) > limit {
			break
		}
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	if items == nil {
		items = []gen.Skill{}
	}
	return gen.ListSkills200JSONResponse{Items: items}, nil
}

func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject) (gen.PatchSkillResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	q := sqlcgen.New(tx)

	if req.Body.Status != nil {
		if err := q.UpdateSkillStatus(ctx, sqlcgen.UpdateSkillStatusParams{
			EntityID: id,
			Status:   string(*req.Body.Status),
		}); err != nil {
			if err == pgx.ErrNoRows {
				return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id)
			}
			return nil, err
		}
	}

	// Re-read skill.
	var skill gen.Skill
	var procBytes []byte
	var patternIDs []uuid.UUID
	err = tx.QueryRow(ctx, `
		SELECT entity_id, version, name, procedure, applies_type, action,
		       pattern_ids, status, success_rate, changed_by::text,
		       change_reason, last_used_at
		FROM skills WHERE entity_id = $1 ORDER BY version DESC LIMIT 1`, id).
		Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType,
			&skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate,
			&skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id)
		}
		return nil, err
	}
	if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil {
			slog.Warn("phase3: unmarshal skill proc", "error", err)
		}
	if len(patternIDs) > 0 {
		pids := make([]string, len(patternIDs))
		for i, pid := range patternIDs {
			pids[i] = pid.String()
		}
		skill.PatternIds = &pids
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, q, actorType, actor, "patch",
		&id, "PATCH", "/api/v1/skills/"+req.Id, "",
		map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.PatchSkill200JSONResponse(skill), nil
}

func (s *Server) ListSkillVersions(ctx context.Context, req gen.ListSkillVersionsRequestObject) (gen.ListSkillVersionsResponseObject, error) {
	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	rows, err := s.pool.Query(ctx, `
		SELECT entity_id, version, name, procedure, applies_type, action,
		       pattern_ids, status, success_rate, changed_by::text,
		       change_reason, last_used_at
		FROM skills WHERE entity_id = $1
		ORDER BY version DESC`, id)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Skill{}
	for rows.Next() {
		var skill gen.Skill
		var procBytes []byte
		var patternIDs []uuid.UUID
		if err := rows.Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType,
			&skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate,
			&skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt); err != nil {
			return nil, err
		}
		if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil {
			slog.Warn("phase3: unmarshal skill proc", "error", err)
		}
		if len(patternIDs) > 0 {
			pids := make([]string, len(patternIDs))
			for i, pid := range patternIDs {
				pids[i] = pid.String()
			}
			skill.PatternIds = &pids
		}
		items = append(items, skill)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}
	if items == nil {
		items = []gen.Skill{}
	}
	return gen.ListSkillVersions200JSONResponse{Items: items}, nil
}

// ─── Approval Rules (Policy) ───────────────────────────────────────────

func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) {
	rows, err := s.pool.Query(ctx, `
		SELECT id, entity_type, action, risk_class, autonomy_level,
		       COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''),
		       version, updated_at
		FROM approval_rules ORDER BY entity_type, action`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.ApprovalRule{}
	for rows.Next() {
		var rule gen.ApprovalRule
		var scopeSlug string
		if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action,
			&rule.RiskClass, &rule.AutonomyLevel, &scopeSlug,
			&rule.Version); err != nil {
			return nil, err
		}
		if scopeSlug != "" {
			rule.ScopeEntity = &scopeSlug
		}
		items = append(items, rule)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}
	if items == nil {
		items = []gen.ApprovalRule{}
	}
	return gen.ListApprovalRules200JSONResponse{Items: items}, nil
}

func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := uuid.NewV7()
	if err != nil {
		return nil, err
	}

	var scopeEntity *uuid.UUID
	if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
		se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
		if rerr != nil {
			return nil, rerr
		}
		scopeEntity = &se
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	_, err = tx.Exec(ctx, `
		INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity)
		VALUES ($1, $2, $3, $4, $5, $6)`,
		id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
		string(req.Body.AutonomyLevel), scopeEntity)
	if err != nil {
		if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
			return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists,
				coalesceStr(req.Body.EntityType, "*"), req.Body.Action)
		}
		return nil, err
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
		&id, "POST", "/api/v1/policy/approval-rules", "",
		map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	// Return 202 pending approval (dual-control).
	return gen.CreateApprovalRule202JSONResponse{}, nil
}

func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	id, err := s.resolveEntityID(ctx, req.Id)
	if err != nil {
		return nil, err
	}

	var scopeEntity *uuid.UUID
	if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
		se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
		if rerr != nil {
			return nil, rerr
		}
		scopeEntity = &se
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	result, err := tx.Exec(ctx, `
		UPDATE approval_rules
		SET entity_type = COALESCE($2, entity_type),
		    action = COALESCE($3, action),
		    risk_class = COALESCE($4, risk_class),
		    autonomy_level = COALESCE($5, autonomy_level),
		    scope_entity = COALESCE($6, scope_entity),
		    version = version + 1,
		    updated_at = now()
		WHERE id = $1`,
		id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
		string(req.Body.AutonomyLevel), scopeEntity)
	if err != nil {
		return nil, err
	}
	if result.RowsAffected() == 0 {
		return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id)
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
		&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
		map[string]any{"action": req.Body.Action}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.PatchApprovalRule202JSONResponse{}, nil
}

// ─── Autonomy Settings ─────────────────────────────────────────────────

func (s *Server) GetAutonomySettings(ctx context.Context, req gen.GetAutonomySettingsRequestObject) (gen.GetAutonomySettingsResponseObject, error) {
	rows, err := s.pool.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.AutonomySetting{}
	for rows.Next() {
		var as gen.AutonomySetting
		if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil {
			return nil, err
		}
		items = append(items, as)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}
	if items == nil {
		items = []gen.AutonomySetting{}
	}
	return gen.GetAutonomySettings200JSONResponse{Items: items}, nil
}

func (s *Server) PatchAutonomySettings(ctx context.Context, req gen.PatchAutonomySettingsRequestObject) (gen.PatchAutonomySettingsResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	for key, value := range *req.Body {
		_, err := tx.Exec(ctx, `
			INSERT INTO autonomy_settings (key, value, version, updated_at)
			VALUES ($1, $2, 1, now())
			ON CONFLICT (key)
			DO UPDATE SET value = EXCLUDED.value, version = autonomy_settings.version + 1, updated_at = now()`,
			key, value)
		if err != nil {
			return nil, err
		}
	}

	// Re-read all settings.
	rows, err := tx.Query(ctx, `SELECT key, value, version, updated_at FROM autonomy_settings ORDER BY key`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.AutonomySetting{}
	for rows.Next() {
		var as gen.AutonomySetting
		if err := rows.Scan(&as.Key, &as.Value, &as.Version, &as.UpdatedAt); err != nil {
			return nil, err
		}
		items = append(items, as)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
		nil, "PATCH", "/api/v1/policy/autonomy", "",
		map[string]any{"keys": keysOfMap(*req.Body)}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.PatchAutonomySettings200JSONResponse{Items: items}, nil
}

// keysOfMap returns the keys of a map[string]string.
func keysOfMap(m map[string]string) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

// ─── Risk Classes ──────────────────────────────────────────────────────

func (s *Server) ListRiskClasses(ctx context.Context, req gen.ListRiskClassesRequestObject) (gen.ListRiskClassesResponseObject, error) {
	rows, err := s.pool.Query(ctx, `SELECT name, description, approval_required, autonomy_allowed FROM risk_classes ORDER BY name`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.RiskClass{}
	for rows.Next() {
		var rc gen.RiskClass
		if err := rows.Scan(&rc.Name, &rc.Description, &rc.ApprovalRequired, &rc.AutonomyAllowed); err != nil {
			return nil, err
		}
		items = append(items, rc)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}
	if items == nil {
		items = []gen.RiskClass{}
	}
	return gen.ListRiskClasses200JSONResponse{Items: items}, nil
}

// ─── Relationships ─────────────────────────────────────────────────────

func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	sourceID, err := s.resolveEntityID(ctx, req.Body.Source)
	if err != nil {
		return nil, err
	}
	targetID, err := s.resolveEntityID(ctx, req.Body.Target)
	if err != nil {
		return nil, err
	}

	attrsJSON := []byte("{}")
	if req.Body.Attributes != nil {
		attrsJSON, _ = json.Marshal(req.Body.Attributes)
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	_, err = tx.Exec(ctx, `
		INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
		VALUES ($1, $2, $3, $4, now())`,
		sourceID, targetID, req.Body.Type, attrsJSON)
	if err != nil {
		if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
			return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists",
				domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target)
		}
		return nil, err
	}

	rel := gen.Relationship{
		Source:    req.Body.Source,
		Target:    req.Body.Target,
		Type:      req.Body.Type,
		ValidFrom: time.Now(),
	}
	if req.Body.Attributes != nil {
		rel.Attributes = req.Body.Attributes
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
		nil, "POST", "/api/v1/relationships", "",
		map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.CreateRelationship201JSONResponse(rel), nil
}

func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) {
	sourceID, err := s.resolveEntityID(ctx, req.Params.Source)
	if err != nil {
		return nil, err
	}
	targetID, err := s.resolveEntityID(ctx, req.Params.Target)
	if err != nil {
		return nil, err
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	result, err := tx.Exec(ctx, `
		UPDATE relationships
		SET valid_to = now()
		WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL`,
		sourceID, targetID, req.Params.RelType)
	if err != nil {
		return nil, err
	}
	if result.RowsAffected() == 0 {
		return nil, fmt.Errorf("%w: active relationship %s:%s:%s",
			domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target)
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
		nil, "DELETE", "/api/v1/relationships", "",
		map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.EndRelationship204Response{}, nil
}

// ─── Entity Types (Ontology) ───────────────────────────────────────────

func (s *Server) CreateEntityType(ctx context.Context, req gen.CreateEntityTypeRequestObject) (gen.CreateEntityTypeResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	isAbstract := false
	if req.Body.IsAbstract != nil {
		isAbstract = *req.Body.IsAbstract
	}

	attrsSchemaJSON := []byte("null")
	if req.Body.AttributeSchema != nil {
		attrsSchemaJSON, _ = json.Marshal(req.Body.AttributeSchema)
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	_, err = tx.Exec(ctx, `
		INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, status)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active')`,
		req.Body.Name, req.Body.ParentType, isAbstract, req.Body.Domain,
		string(req.Body.Layer), req.Body.Description, req.Body.LifecycleId, attrsSchemaJSON)
	if err != nil {
		if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
			return nil, fmt.Errorf("%w: entity type %q already exists", domain.ErrAlreadyExists, req.Body.Name)
		}
		return nil, err
	}

	// Re-read.
	var et gen.EntityType
	var schemaBytes []byte
	err = tx.QueryRow(ctx, `
		SELECT name, parent_type, is_abstract, domain, layer, description,
		       lifecycle_id, attribute_schema, schema_version, status
		FROM entity_types WHERE name = $1`, req.Body.Name).
		Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer,
			&et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status)
	if err != nil {
		return nil, err
	}
	var schema map[string]any
	if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil {
		et.AttributeSchema = &schema
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
		nil, "POST", "/api/v1/ontology/entity-types", "",
		map[string]any{"name": req.Body.Name, "domain": req.Body.Domain}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.CreateEntityType201JSONResponse(et), nil
}

func (s *Server) PatchEntityType(ctx context.Context, req gen.PatchEntityTypeRequestObject) (gen.PatchEntityTypeResponseObject, error) {
	if req.Body == nil {
		return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
	}

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	// Build dynamic update.
	sets := []string{}
	args := []any{}
	argIdx := 2

	if req.Body.Description != nil {
		sets = append(sets, fmt.Sprintf("description = $%d", argIdx))
		args = append(args, *req.Body.Description)
		argIdx++
	}
	if req.Body.Status != nil {
		sets = append(sets, fmt.Sprintf("status = $%d", argIdx))
		args = append(args, string(*req.Body.Status))
		argIdx++
	}
	if req.Body.AttributeSchema != nil {
		schemaJSON, _ := json.Marshal(req.Body.AttributeSchema)
		sets = append(sets, fmt.Sprintf("attribute_schema = $%d", argIdx))
		args = append(args, schemaJSON)
		argIdx++
	}

	if len(sets) == 0 {
		return nil, fmt.Errorf("%w: no fields to update", domain.ErrInvalidInput)
	}

	sets = append(sets, "schema_version = schema_version + 1, updated_at = now()")

	query := fmt.Sprintf(`UPDATE entity_types SET %s WHERE name = $1`, strings.Join(sets, ", "))
	finalArgs := append([]any{req.Name}, args...)

	result, err := tx.Exec(ctx, query, finalArgs...)
	if err != nil {
		return nil, err
	}
	if result.RowsAffected() == 0 {
		return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Name)
	}

	// Re-read.
	var et gen.EntityType
	var schemaBytes []byte
	err = tx.QueryRow(ctx, `
		SELECT name, parent_type, is_abstract, domain, layer, description,
		       lifecycle_id, attribute_schema, schema_version, status
		FROM entity_types WHERE name = $1`, req.Name).
		Scan(&et.Name, &et.ParentType, &et.IsAbstract, &et.Domain, &et.Layer,
			&et.Description, &et.LifecycleId, &schemaBytes, &et.SchemaVersion, &et.Status)
	if err != nil {
		return nil, err
	}
	var schema map[string]any
	if len(schemaBytes) > 0 && json.Unmarshal(schemaBytes, &schema) == nil && schema != nil {
		et.AttributeSchema = &schema
	}

	actorType, actor := actorInfo(ctx)
	if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
		nil, "PATCH", "/api/v1/ontology/entity-types/"+req.Name, "",
		map[string]any{"status": req.Body.Status}); auditErr != nil {
		return nil, auditErr
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	return gen.PatchEntityType200JSONResponse(et), nil
}

// ─── Metrics ───────────────────────────────────────────────────────────

func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) {
	if req.Params.EntityId == nil || *req.Params.EntityId == "" {
		return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
	}
	entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
	if err != nil {
		return nil, err
	}

	from := time.Now().Add(-24 * time.Hour)
	if req.Params.From != nil {
		from = *req.Params.From
	}
	to := time.Now()
	if req.Params.To != nil {
		to = *req.Params.To
	}

	var metricNames []string
	if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
		metricNames = *req.Params.Metric
	} else {
		// metric omitted: report every metric recorded for this entity in range.
		rows, err := s.pool.Query(ctx, `
			SELECT DISTINCT metric FROM metric_samples
			WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
			ORDER BY metric`, entityID, from, to)
		if err != nil {
			return nil, err
		}
		for rows.Next() {
			var name string
			if err := rows.Scan(&name); err != nil {
				rows.Close()
				return nil, err
			}
			metricNames = append(metricNames, name)
		}
		if err := rows.Err(); err != nil {
			return nil, err
		}
	}

	items := []gen.MetricSeries{}
	for _, metricName := range metricNames {
		series := gen.MetricSeries{
			EntityId: entityID.String(),
			Metric:   metricName,
			Rollup:   gen.MetricSeriesRollupRaw,
		}

		rows, err := s.pool.Query(ctx, `
			SELECT ts, value
			FROM metric_samples
			WHERE entity_id = $1 AND metric = $2
			  AND ts >= $3 AND ts <= $4
			ORDER BY ts ASC`,
			entityID, metricName, from, to)
		if err != nil {
			return nil, err
		}

		samples := []struct {
			Avg   *float32  `json:"avg"`
			Count *int      `json:"count"`
			Max   *float32  `json:"max"`
			Min   *float32  `json:"min"`
			Ts    time.Time `json:"ts"`
			Value *float32  `json:"value"`
		}{}

		for rows.Next() {
			var ts time.Time
			var val float64
			if err := rows.Scan(&ts, &val); err != nil {
				rows.Close()
				return nil, err
			}
			f := float32(val)
			samples = append(samples, struct {
				Avg   *float32  `json:"avg"`
				Count *int      `json:"count"`
				Max   *float32  `json:"max"`
				Min   *float32  `json:"min"`
				Ts    time.Time `json:"ts"`
				Value *float32  `json:"value"`
			}{Value: &f, Ts: ts})
		}
		rows.Close()
		if rows.Err() != nil {
			return nil, rows.Err()
		}

		series.Samples = samples
		items = append(items, series)
	}

	if items == nil {
		items = []gen.MetricSeries{}
	}
	return gen.QueryMetrics200JSONResponse{Items: items}, nil
}

func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) {
	entityID, err := s.resolveEntityID(ctx, req.EntityId)
	if err != nil {
		return nil, err
	}

	from := time.Now().Add(-7 * 24 * time.Hour)
	if req.Params.From != nil {
		from = *req.Params.From
	}

	rows, err := s.pool.Query(ctx, `
		SELECT metric,
		       ROUND(avg(value)::numeric, 2) AS avg_val,
		       ROUND(stddev(value)::numeric, 2) AS std_val,
		       count(*) AS sample_count,
		       ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
		FROM metric_samples
		WHERE entity_id = $1 AND ts >= $2
		GROUP BY metric
		ORDER BY metric`, entityID, from)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.Trend{}
	for rows.Next() {
		var t gen.Trend
		var avgVal, stdVal, slopeNum pgtype.Numeric
		var sampleCount int
		if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil {
			return nil, err
		}

		// Determine direction.
		if slopeNum.Valid {
			f, _ := slopeNum.Float64Value()
			t.Slope = float32Ptr(float32(f.Float64))
			if f.Float64 > 0.01 {
				t.Direction = gen.Improving
			} else if f.Float64 < -0.01 {
				t.Direction = gen.Degrading
			} else {
				t.Direction = gen.Stable
			}
		} else {
			t.Direction = gen.Unknown
		}
		items = append(items, t)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}
	if items == nil {
		items = []gen.Trend{}
	}
	return gen.GetTrends200JSONResponse{Items: items}, nil
}

func float32Ptr(f float32) *float32 {
	return &f
}

// ─── Agent Activity (stub) ─────────────────────────────────────────────

func (s *Server) QueryAgentActivity(ctx context.Context, request gen.QueryAgentActivityRequestObject) (gen.QueryAgentActivityResponseObject, error) {
	limit := clampLimit(request.Params.Limit)
	from := time.Now().Add(-24 * time.Hour)
	if request.Params.From != nil {
		from = *request.Params.From
	}
	to := time.Now()
	if request.Params.To != nil {
		to = *request.Params.To
	}

	var agentID *string
	if request.Params.AgentId != nil {
		a := *request.Params.AgentId
		agentID = &a
	}
	var activityType *string
	if request.Params.ActivityType != nil {
		a := string(*request.Params.ActivityType)
		activityType = &a
	}
	var entityID *string
	if request.Params.EntityId != nil {
		a := *request.Params.EntityId
		entityID = &a
	}
	var cursorID *int
	if request.Params.Cursor != nil && *request.Params.Cursor != "" {
		if id, err := parseIntOrZero(*request.Params.Cursor); err == nil && id > 0 {
			cursorID = &id
		}
	}

	rows, err := s.pool.Query(ctx, `
		SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
		       entity_id::text, input_summary, output_summary,
		       duration_ms, token_count, success, correlation_id
		FROM agent_activity
		WHERE ts >= $1 AND ts <= $2
		  AND ($3::text IS NULL OR agent_id::text = $3)
		  AND ($4::text IS NULL OR activity_type = $4)
		  AND ($5::text IS NULL OR entity_id::text = $5)
		  AND ($6::bigint IS NULL OR id < $6::bigint)
		ORDER BY id DESC
		LIMIT $7`,
		from, to, agentID, activityType, entityID, cursorID, limit+1)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := []gen.AgentActivity{}
	for rows.Next() {
		var a gen.AgentActivity
		if err := rows.Scan(&a.Id, &a.Ts, &a.AgentId, &a.SessionId,
			&a.ActivityType, &a.ToolName, &a.EntityId,
			&a.InputSummary, &a.OutputSummary,
			&a.DurationMs, &a.TokenCount, &a.Success,
			&a.CorrelationId); err != nil {
			return nil, err
		}
		items = append(items, a)
	}
	if rows.Err() != nil {
		return nil, rows.Err()
	}

	var next *string
	if len(items) > limit {
		items = items[:limit]
		lastID := fmt.Sprintf("%d", items[len(items)-1].Id)
		next = &lastID
	}
	if items == nil {
		items = []gen.AgentActivity{}
	}
	return gen.QueryAgentActivity200JSONResponse{Items: items, NextCursor: next}, nil
}

// ─── Helpers ───────────────────────────────────────────────────────────

func coalesceStr(s *string, def string) string {
	if s == nil || *s == "" {
		return def
	}
	return *s
}

func parseIntOrZero(s string) (int, error) {
	var n int
	for _, c := range s {
		if c < '0' || c > '9' {
			return 0, fmt.Errorf("invalid integer: %q", s)
		}
		n = n*10 + int(c-'0')
	}
	return n, nil
}

func hashToken(token string) string {
	h := sha256.Sum256([]byte(token))
	return hex.EncodeToString(h[:])
}
