// Package scheduler implements the Oikos observe + decide loop (Phase 3).
// It loads enabled check_defs, runs checks on schedule, manages signal
// lifecycle (dedup, flap suppression, maintenance mode), and writes metrics.
package scheduler

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/dtoro/oikos/internal/actuator"
	"github.com/dtoro/oikos/internal/config"
	"github.com/dtoro/oikos/internal/adapters/postgres"
	"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
	"github.com/dtoro/oikos/internal/health"
	"github.com/dtoro/oikos/internal/observability"
	"github.com/dtoro/oikos/internal/remote"
	"github.com/google/uuid"
	"golang.org/x/sync/errgroup"
	"golang.org/x/sys/unix"
)

var (
	sshKeyPath string
	sshUser    string
	sshPool    *actuator.DialPool
)

// schedulerLockKey is the advisory-lock key preventing duplicate scheduler
// instances. Must differ from db.migrationLockKey (0x01c05e5).
const schedulerLockKey = 0x01c05e6

// Run starts the scheduler loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
	slog.Info("scheduler: starting", "interval", cfg.SchedulerInterval)
	interval := cfg.SchedulerInterval
	if interval <= 0 {
		interval = 30 * time.Second
	}

	sshKeyPath = cfg.SSHKeyPath
	sshUser = cfg.SSHUser
	if sshUser == "" {
		sshUser = "root"
	}
	sshPool = actuator.NewDialPool(5 * time.Minute)
	defer sshPool.Close()

	// Acquire a session-level advisory lock so only one scheduler instance
	// runs at a time. If another instance holds the lock, we exit — duplicate
	// schedulers would duplicate health checks, signals, metrics, and events.
	lockConn, err := pool.Acquire(ctx)
	if err != nil {
		slog.Error("scheduler: acquire connection for lock", "error", err)
		return
	}
	var locked bool
	if err := lockConn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", schedulerLockKey).Scan(&locked); err != nil {
		lockConn.Release()
		slog.Error("scheduler: advisory lock error", "error", err)
		return
	}
	if !locked {
		lockConn.Release()
		slog.Warn("scheduler: advisory lock held by another instance, exiting")
		return
	}
	defer func() {
		lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", schedulerLockKey)
		lockConn.Release()
	}()

	// Liveness probe (plan D5): staleness is 3x the interval so a single
	// slow check pass (one host hung on SSH) doesn't flap the container
	// unhealthy before the next scheduled tick.
	stale := 3 * interval
	if stale < 90*time.Second {
		stale = 90 * time.Second
	}
	probe := health.New(stale)
	probe.Serve(ctx, cfg.HealthListen)

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	// Immediate first pass
	runCheckPass(ctx, pool)
	probe.Bump()

	for {
		select {
		case <-ctx.Done():
			slog.Info("scheduler: shutting down")
			return
		case <-ticker.C:
			runCheckPass(ctx, pool)
			probe.Bump()
		}
	}
}

// runCheckPass executes one full cycle of check evaluation.
func runCheckPass(ctx context.Context, pool *db.Pool) {
	q := sqlcgen.New(pool)

	defs, err := q.ListEnabledCheckDefs(ctx)
	if err != nil {
		slog.Error("scheduler: list check defs", "error", err)
		return
	}
	if len(defs) == 0 {
		// Still run housekeeping: a fleet with no enabled check_defs is
		// precisely the case coverageSweep exists to report, and returning
		// here would mean the one situation that most needs reporting is the
		// one situation that stays silent.
		slog.Debug("scheduler: no enabled check_defs")
		housekeeping(ctx, pool)
		return
	}

	g, gctx := errgroup.WithContext(ctx)
	g.SetLimit(10) // bounded worker pool

	for _, def := range defs {
		cd := def
		g.Go(func() error {
			runCheck(gctx, pool, cd)
			return nil
		})
	}
	g.Wait()

	// Housekeeping after each pass
	housekeeping(ctx, pool)
}

// runCheck executes a single check and processes the result.
//
// check_defs.entity_id identifies the *check* (probe) entity itself;
// check_defs.target_id identifies the entity actually being observed (the
// host/service/etc). Health, metrics, and events must attach to the target
// so the observed entity's own record reflects reality — not the internal
// probe. Signals stay keyed by the check entity (cd.EntityID), matching how
// they are created below and resolved elsewhere.
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
	q := sqlcgen.New(pool)
	start := time.Now()

	result := executeCheck(ctx, pool, cd)

	// Stamp the run before processing the result: due-ness must advance even
	// when a check fails, or a permanently failing check would be re-run on
	// every pass instead of at its declared interval. last_health records THIS
	// check's own verdict, which is what makes the aggregation below possible.
	checkHealth := result.health
	if checkHealth == "" {
		checkHealth = "healthy"
	}
	if err := q.MarkCheckRun(ctx, sqlcgen.MarkCheckRunParams{
		EntityID:   cd.EntityID,
		LastHealth: &checkHealth,
	}); err != nil {
		slog.Error("scheduler: mark check run", "entity", cd.EntitySlug, "error", err)
	}

	latency := time.Since(start).Milliseconds()

	if result.metrics == nil {
		result.metrics = make(map[string]float64)
	}
	result.metrics["probe_latency_ms"] = float64(latency)

	targetID := cd.EntityID
	if cd.TargetID != nil {
		targetID = *cd.TargetID
	}

	for metric, value := range result.metrics {
		_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
			EntityID: targetID,
			Metric:   metric,
			Value:    value,
			Tags:     []byte(`{}`),
		})
	}

	if result.err != nil {
		slog.Warn("scheduler: check failed",
			"entity", cd.EntitySlug, "kind", cd.Kind, "error", result.err)
	}

	prevHealth := currentHealth(ctx, pool, targetID)

	if result.signalKind == "" || result.health == "healthy" {
		resolveSignal(ctx, pool, cd.EntityID, targetID, cd.EntitySlug)
		// NOT unconditionally "healthy": this check passing says nothing about
		// the entity's other checks. Writing healthy here is what let one
		// passing probe erase a genuine failure reported by another — and,
		// alternating with a failing probe, produced 226 health flips an hour
		// on a host that was fine throughout.
		applyAggregateHealth(ctx, pool, q, targetID, cd.EntitySlug, prevHealth)
		return
	}

	slog.Warn("scheduler: raising signal",
		"entity", cd.EntitySlug, "kind", result.signalKind, "evidence", result.evidence)

	severity := evaluateSeverity(cd.Kind, result.signalKind, cd.Config, result.metrics)

	sig, err := q.UpsertSignal(ctx, sqlcgen.UpsertSignalParams{
		EntityID:       cd.EntityID,
		Kind:           result.signalKind,
		Severity:       severity,
		TargetEntityID: cd.TargetID,
		Evidence:       &result.evidence,
	})
	if err != nil {
		slog.Error("scheduler: upsert signal", "error", err)
		return
	}

	_ = sig

	if prevHealth == "" || prevHealth == "healthy" {
		emitSchedulerEvent(ctx, pool, "signal.raised", targetID, severity,
			map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
	}
	applyAggregateHealth(ctx, pool, q, targetID, cd.EntitySlug, prevHealth)
}

// applyAggregateHealth sets the target's health to the worst verdict across
// all of its enabled checks, and emits health.changed only when that aggregate
// actually moves.
//
// Health is a property of the entity, but each check only ever observes one
// facet of it — reachability, disk, a systemd unit. Letting whichever check
// finished last overwrite the entity's health meant a host with six checks
// reported whichever facet was sampled most recently, so one failing probe and
// five passing ones oscillated forever instead of settling on "degraded".
func applyAggregateHealth(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
	targetID uuid.UUID, checkSlug, prevHealth string) {

	health, err := q.WorstHealthForTarget(ctx, &targetID)
	if err != nil {
		slog.Error("scheduler: aggregate health", "entity", checkSlug, "error", err)
		return
	}

	_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
		EntityID:    targetID,
		Health:      health,
		LastCheckAt: &[]time.Time{time.Now()}[0],
		Details:     []byte(`{}`),
	})

	if prevHealth == health {
		return
	}
	severity := "info"
	switch health {
	case "down":
		severity = "critical"
	case "degraded", "stale":
		severity = "warning"
	}
	emitSchedulerEvent(ctx, pool, "health.changed", targetID, severity,
		map[string]any{"slug": checkSlug, "from": prevHealth, "to": health})
}

// currentHealth reads the last recorded health for an entity, or "" if none.
func currentHealth(ctx context.Context, pool *db.Pool, entityID uuid.UUID) string {
	var health string
	if err := pool.QueryRow(ctx,
		`SELECT health FROM entity_status WHERE entity_id = $1`, entityID).Scan(&health); err != nil {
		return ""
	}
	return health
}

// emitSchedulerEvent records a scheduler-sourced event for SSE fan-out.
func emitSchedulerEvent(ctx context.Context, pool *db.Pool, eventType string, entityID uuid.UUID, severity string, data map[string]any) {
	_ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data)
}

// resolveSignal resolves any open signal raised by the given check entity.
// checkID matches how signals are keyed (UpsertSignal uses the check's own
// entity id); targetID is the observed entity whose status this affects.
func resolveSignal(ctx context.Context, pool *db.Pool, checkID, targetID uuid.UUID, slug string) {
	// Check if there's an open signal on this entity
	tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
		WHERE entity_id = $1 AND state = 'raised'`, checkID)
	if err != nil {
		return
	}
	if tag.RowsAffected() > 0 {
		emitSchedulerEvent(ctx, pool, "signal.resolved", targetID, "info",
			map[string]any{"slug": slug})
		slog.Info("scheduler: signal resolved", "entity", slug)
	}
	// Deliberately does NOT write health. Resolving THIS check's signal says
	// nothing about the target's other checks; the caller re-derives health
	// from all of them. Forcing "healthy" here was a second path by which one
	// passing probe erased another probe's genuine failure.
}

// checkResult bundles the outcome of a single check execution.
type checkResult struct {
	health     string
	signalKind string
	evidence   string
	metrics    map[string]float64
	err        error
}

// executeCheck dispatches to the appropriate checker by kind. pool is needed
// by the ssh-script path, which resolves the target's execution endpoint
// (guests route through their Proxmox host; see internal/remote).
func executeCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	switch cd.Kind {
	case "http":
		return checkHTTP(ctx, cd)
	case "tcp":
		return checkTCP(ctx, cd)
	case "disk":
		return checkDisk(ctx, cd)
	case "cert-expiry":
		return checkCertExpiry(ctx, cd)
	case "vm-status":
		return checkVMStatus(ctx, pool, cd)
	case "ping":
		return checkPing(ctx, cd)
	case "ssh-script":
		return checkSSHScript(ctx, pool, cd)
	case "backup-freshness":
		return checkBackupFreshness(ctx, cd)
	case "dns":
		return checkDNS(ctx, cd)
	default:
		return checkResult{health: "unknown"}
	}
}

// housekeeping runs background maintenance tasks.
func housekeeping(ctx context.Context, pool *db.Pool) {

	// Prune expired idempotency keys (older than 24h)
	cutoff := time.Now().Add(-24 * time.Hour)
	_, err := pool.Exec(ctx,
		"DELETE FROM idempotency_keys WHERE created_at < $1", cutoff)
	if err != nil {
		slog.Error("scheduler: prune idempotency keys", "error", err)
	}

	staleSweep(ctx, pool)
	coverageSweep(ctx, pool)

	// Log housekeeping completion
	slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
}

// staleMultiplier and staleFloor bound how long an entity can go unobserved
// before its last-known health is no longer trusted. An entity is stale once
// it has gone longer than staleMultiplier times its fastest enabled check's
// interval (or staleFloor, whichever is larger) without a fresh observation —
// covering both a stalled scheduler and a disabled/broken check_def.
const (
	staleMultiplier = 3
	staleFloor      = 5 * time.Minute
)

// staleSweep marks entities whose last observation has aged past their
// check's expected cadence as 'stale', so the system never reports an old
// health value as if it were current. Runs once per housekeeping pass.
func staleSweep(ctx context.Context, pool *db.Pool) {
	rows, err := pool.Query(ctx, `
		SELECT e.id, e.slug, st.health
		FROM entity_status st
		JOIN entities e ON e.id = st.entity_id
		JOIN (
			SELECT target_id, MIN(interval_s) AS min_interval
			FROM check_defs
			WHERE enabled AND target_id IS NOT NULL
			GROUP BY target_id
		) iv ON iv.target_id = st.entity_id
		WHERE st.health <> 'stale'
		  AND (st.last_check_at IS NULL
		       OR st.last_check_at < now() - make_interval(secs => GREATEST(iv.min_interval * $1, $2)))`,
		staleMultiplier, int(staleFloor.Seconds()))
	if err != nil {
		slog.Error("scheduler: stale sweep query", "error", err)
		return
	}
	defer rows.Close()

	type staleEntity struct {
		id     uuid.UUID
		slug   string
		health string
	}
	var stale []staleEntity
	for rows.Next() {
		var se staleEntity
		if err := rows.Scan(&se.id, &se.slug, &se.health); err != nil {
			continue
		}
		stale = append(stale, se)
	}
	rows.Close()

	for _, se := range stale {
		_, err := pool.Exec(ctx,
			`UPDATE entity_status SET health = 'stale', updated_at = now() WHERE entity_id = $1`, se.id)
		if err != nil {
			slog.Error("scheduler: mark stale", "entity", se.slug, "error", err)
			continue
		}
		slog.Warn("scheduler: entity stale", "entity", se.slug, "prev_health", se.health)
		emitSchedulerEvent(ctx, pool, "health.stale", se.id, "warning",
			map[string]any{"slug": se.slug, "from": se.health, "to": "stale"})
	}
}

// checkHTTP performs an HTTP health check.
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	cfg := struct {
		URL            string `json:"url"`
		ExpectedStatus int    `json:"expected_status"`
		// MaxStatus accepts a range instead of one exact code. Most services
		// sit behind Authentik and answer 302 or 401 — a working service, but
		// an exact-match on 200 reports it degraded and raises a signal.
		// Unset expected_status means "any response below MaxStatus is fine".
		MaxStatus int  `json:"max_status"`
		Insecure  bool `json:"insecure"`
	}{
		MaxStatus: 500,
	}
	if len(cd.Config) > 0 {
		_ = json.Unmarshal(cd.Config, &cfg)
	}
	if cfg.URL == "" {
		return checkResult{health: "healthy"}
	}

	timeout := time.Duration(cd.TimeoutS) * time.Second
	if timeout <= 0 {
		timeout = 30 * time.Second
	}

	client := &http.Client{
		Timeout: timeout,
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.Insecure},
		},
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
	if err != nil {
		return checkResult{
			health: "down", signalKind: "http",
			evidence: fmt.Sprintf("invalid URL %q: %v", cfg.URL, err),
			err:      err,
		}
	}

	resp, err := client.Do(req)
	if err != nil {
		return checkResult{
			health: "down", signalKind: "http",
			evidence: fmt.Sprintf("GET %s: %v", cfg.URL, err),
			err:      err,
		}
	}
	defer resp.Body.Close()

	if cfg.ExpectedStatus != 0 {
		if resp.StatusCode != cfg.ExpectedStatus {
			return checkResult{
				health: "degraded", signalKind: "http",
				evidence: fmt.Sprintf("GET %s returned %d (expected %d)", cfg.URL, resp.StatusCode, cfg.ExpectedStatus),
			}
		}
	} else if resp.StatusCode >= cfg.MaxStatus {
		return checkResult{
			health: "degraded", signalKind: "http",
			evidence: fmt.Sprintf("GET %s returned %d (expected below %d)", cfg.URL, resp.StatusCode, cfg.MaxStatus),
		}
	}

	return checkResult{health: "healthy"}
}

// checkTCP performs a TCP dial check.
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	cfg := struct {
		Host string `json:"host"`
		Port int    `json:"port"`
	}{}
	if len(cd.Config) > 0 {
		_ = json.Unmarshal(cd.Config, &cfg)
	}
	if cfg.Host == "" || cfg.Port == 0 {
		return checkResult{health: "healthy"}
	}

	timeout := time.Duration(cd.TimeoutS) * time.Second
	if timeout <= 0 {
		timeout = 30 * time.Second
	}

	addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
	conn, err := net.DialTimeout("tcp", addr, timeout)
	if err != nil {
		return checkResult{
			health: "down", signalKind: "tcp",
			evidence: fmt.Sprintf("dial %s: %v", addr, err),
			err:      err,
		}
	}
	conn.Close()
	return checkResult{health: "healthy"}
}

// checkDNS verifies a DNS name resolves, catching a stale or unreachable
// zone. It looks up NS records first (a zone always has NS), falling back to
// an A/AAAA lookup for hostnames. Uses the system resolver; for split-horizon
// correctness reserve an explicit `server` in the config.
func checkDNS(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	cfg := struct {
		Name   string `json:"name"`
		Server string `json:"server"`
	}{}
	if len(cd.Config) > 0 {
		_ = json.Unmarshal(cd.Config, &cfg)
	}
	if cfg.Name == "" {
		return checkResult{health: "healthy"}
	}

	// Resolve via an explicit server when supplied (split-horizon), else the
	// system default resolver.
	lookup := func(q string) (int, error) {
		r := &net.Resolver{}
		if cfg.Server != "" {
			r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
				d := net.Dialer{Timeout: 5 * time.Second}
				return d.DialContext(ctx, network, net.JoinHostPort(cfg.Server, "53"))
			}}
		}
		ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
		defer cancel()
		ns, err := r.LookupNS(ctx, q)
		if err == nil && len(ns) > 0 {
			return len(ns), nil
		}
		addrs, err2 := r.LookupHost(ctx, q)
		return len(addrs), err2
	}

	n, err := lookup(cfg.Name)
	if err != nil || n == 0 {
		return checkResult{
			health: "down", signalKind: "dns",
			evidence: fmt.Sprintf("DNS resolution failed for %q: %v", cfg.Name, err),
			err:      err,
		}
	}
	return checkResult{health: "healthy"}
}

// checkDisk performs a disk usage check.
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	cfg := struct {
		Path         string `json:"path"`
		ThresholdPct int    `json:"threshold_pct"`
	}{
		Path:         "/",
		ThresholdPct: 85,
	}
	if len(cd.Config) > 0 {
		_ = json.Unmarshal(cd.Config, &cfg)
	}

	var stat unix.Statfs_t
	if err := unix.Statfs(cfg.Path, &stat); err != nil {
		return checkResult{
			health: "down", signalKind: "disk",
			evidence: fmt.Sprintf("statfs %s: %v", cfg.Path, err),
			err:      err,
		}
	}

	total := stat.Blocks * uint64(stat.Bsize)
	free := stat.Bfree * uint64(stat.Bsize)
	if total == 0 {
		return checkResult{health: "healthy"}
	}

	usedPct := float64(total-free) / float64(total) * 100
	inodePct := 0.0
	if stat.Files > 0 {
		inodePct = float64(stat.Files-stat.Ffree) / float64(stat.Files) * 100
	}

	metrics := map[string]float64{
		"disk_used_pct":  usedPct,
		"disk_inode_pct": inodePct,
	}

	if usedPct > float64(cfg.ThresholdPct) {
		return checkResult{
			health: "degraded", signalKind: "disk",
			evidence: fmt.Sprintf("%s %.1f%% full (threshold %d%%)", cfg.Path, usedPct, cfg.ThresholdPct),
			metrics:  metrics,
		}
	}

	return checkResult{health: "healthy", metrics: metrics}
}

// checkCertExpiry checks TLS certificate expiry.
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	cfg := struct {
		Host string `json:"host"`
		// Dial is an optional explicit dial address (the TLS terminator's IP)
		// for when the hostname doesn't resolve/reach from the scheduler — the
		// container has no mesh interface and the host resolver doesn't know
		// the split-horizon zone, so *.hubris.network dials Caddy's lab IP
		// directly while SNI/cert-read still uses Host.
		Dial     string `json:"dial"`
		Port     int    `json:"port"`
		WarnDays int    `json:"warn_days"`
		CritDays int    `json:"crit_days"`
	}{
		Port:     443,
		WarnDays: 30,
		CritDays: 7,
	}
	if len(cd.Config) > 0 {
		_ = json.Unmarshal(cd.Config, &cfg)
	}
	if cfg.Host == "" {
		return checkResult{health: "healthy"}
	}

	timeout := time.Duration(cd.TimeoutS) * time.Second
	if timeout <= 0 {
		timeout = 30 * time.Second
	}

	dialHost := cfg.Host
	if cfg.Dial != "" {
		dialHost = cfg.Dial
	}
	addr := net.JoinHostPort(dialHost, fmt.Sprintf("%d", cfg.Port))

	d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true, ServerName: cfg.Host}}
	conn, err := d.DialContext(ctx, "tcp", addr)
	if err != nil {
		return checkResult{
			health: "down", signalKind: "cert-expiry",
			evidence: fmt.Sprintf("TLS dial %s: %v", addr, err),
			err:      err,
		}
	}
	defer conn.Close()

	tlsConn := conn.(*tls.Conn)
	cs := tlsConn.ConnectionState()
	if len(cs.PeerCertificates) == 0 {
		return checkResult{
			health: "down", signalKind: "cert-expiry",
			evidence: "no peer certificates",
		}
	}

	cert := cs.PeerCertificates[0]
	daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)

	metrics := map[string]float64{
		"cert_days_left": float64(daysLeft),
	}

	if daysLeft <= cfg.CritDays {
		return checkResult{
			health: "down", signalKind: "cert-expiry",
			evidence: fmt.Sprintf("%s expires in %d days (crit=%d)", cfg.Host, daysLeft, cfg.CritDays),
			metrics:  metrics,
		}
	}
	if daysLeft <= cfg.WarnDays {
		return checkResult{
			health: "degraded", signalKind: "cert-expiry",
			evidence: fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays),
			metrics:  metrics,
		}
	}

	return checkResult{health: "healthy", metrics: metrics}
}

// checkVMStatus reports whether a VM is powered on, via `qm status <pve_id>`
// run on its Proxmox host. This is the right reachability probe for a VM that
// blocks ICMP (haos) and has no guest agent: it doesn't need the VM's network
// at all — "status: running" means the VM is up. The command runs on the host
// (not inside the VM), so it uses the host's address with identity wrap.
func checkVMStatus(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	if cd.TargetID == nil {
		return checkResult{health: "unknown", evidence: "vm-status needs a target VM"}
	}
	var pveID, hostAttr string
	if err := pool.QueryRow(ctx,
		"SELECT attributes->>'pve_id', COALESCE(attributes->>'host','') FROM entities WHERE id = $1",
		*cd.TargetID).Scan(&pveID, &hostAttr); err != nil || pveID == "" {
		return checkResult{health: "unknown", signalKind: "vm-status",
			evidence: fmt.Sprintf("vm %s has no pve_id", cd.EntitySlug)}
	}
	hostSlug := remote.ResolveProxmoxHostSlug(ctx, pool, *cd.TargetID, hostAttr)
	addr, user, err := remote.ResolveHost(ctx, pool, hostSlug, sshUser)
	if err != nil {
		return checkResult{health: "down", signalKind: "vm-status",
			evidence: fmt.Sprintf("resolve proxmox host for %s: %v", cd.EntitySlug, err), err: err}
	}

	timeout := time.Duration(cd.TimeoutS) * time.Second
	if timeout <= 0 {
		timeout = 15 * time.Second
	}
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	out, err := sshExec(ctx, addr, "22", user, "qm status "+pveID, timeout)
	if err != nil {
		return checkResult{health: "down", signalKind: "vm-status",
			evidence: fmt.Sprintf("qm status %s on %s: %v", pveID, addr, err), err: err}
	}
	// `qm status <id>` prints "status: running" (or stopped/paused).
	if strings.Contains(string(out), "status: running") {
		return checkResult{health: "healthy"}
	}
	trimmed := strings.TrimSpace(string(out))
	if trimmed == "" {
		trimmed = "(no output)"
	}
	return checkResult{health: "down", signalKind: "vm-status",
		evidence: fmt.Sprintf("%s not running: %s", cd.EntitySlug, trimmed)}
}

// checkPing performs an ICMP ping check using the system ping command.
func checkPing(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	cfg := struct {
		Host  string `json:"host"`
		Count int    `json:"count"`
		// Port for the TCP fallback below. Defaults to 22; set it for hosts
		// that answer on something else (a Home Assistant VM has no sshd).
		Port int `json:"port"`
	}{}
	if len(cd.Config) > 0 {
		_ = json.Unmarshal(cd.Config, &cfg)
	}
	if cfg.Host == "" {
		return checkResult{health: "healthy"}
	}
	if cfg.Count <= 0 {
		cfg.Count = 1
	}

	timeout := time.Duration(cd.TimeoutS) * time.Second
	if timeout <= 0 {
		timeout = 10 * time.Second
	}

	deadline := time.Duration(cfg.Count+1) * timeout
	ctx, cancel := context.WithTimeout(ctx, deadline)
	defer cancel()

	countStr := strconv.Itoa(cfg.Count)
	timeoutSec := strconv.Itoa(int(timeout.Seconds()))
	if timeoutSec == "0" {
		timeoutSec = "1"
	}

	cmd := exec.CommandContext(ctx, "ping", "-c", countStr, "-W", timeoutSec, cfg.Host)
	if runtime.GOOS == "darwin" {
		cmd = exec.CommandContext(ctx, "ping", "-c", countStr, "-t", timeoutSec, cfg.Host)
	}

	output, err := cmd.Output()
	if err != nil {
		// ICMP failing does not mean the host is down — it may mean ICMP is
		// simply unavailable from here. On this deployment the scheduler runs
		// in Docker on macOS, whose VM network stack does not route ICMP to
		// the LAN: loopback pings succeed, every LAN ping fails, and all seven
		// ping checks reported "down" for hosts that were demonstrably up
		// (including the Docker host itself). Under health aggregation that one
		// broken probe was enough to drag each entity to down.
		//
		// The question this check exists to answer is "is it reachable", and
		// ICMP is only one way to ask. Fall back to a TCP connect before
		// concluding anything.
		if tcpReachable(ctx, cfg.Host, cfg.Port, timeout) {
			return checkResult{
				health:  "healthy",
				metrics: map[string]float64{},
			}
		}
		return checkResult{
			health: "down", signalKind: "ping",
			evidence: fmt.Sprintf("no ICMP or TCP response from %s: %v", cfg.Host, err),
			err:      err,
		}
	}

	latency := parsePingLatency(output)
	metrics := map[string]float64{}
	if latency > 0 {
		metrics["ping_latency_ms"] = latency
	}

	return checkResult{health: "healthy", metrics: metrics}
}

// tcpReachable reports whether a TCP handshake completes, used as the
// reachability answer when ICMP is unavailable rather than unanswered.
func tcpReachable(ctx context.Context, host string, port int, timeout time.Duration) bool {
	if port == 0 {
		port = 22
	}
	if timeout <= 0 {
		timeout = 5 * time.Second
	}
	d := net.Dialer{Timeout: timeout}
	conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
	if err != nil {
		return false
	}
	conn.Close()
	return true
}

var pingRttRe = regexp.MustCompile(`(?:rtt\s+min\/avg\/max\/mdev|round-trip\s+min\/avg\/max\/stddev)\s*=\s*[\d.]+\/([\d.]+)\/`)

func parsePingLatency(output []byte) float64 {
	matches := pingRttRe.FindSubmatch(output)
	if len(matches) < 2 {
		return 0
	}
	val, err := strconv.ParseFloat(string(matches[1]), 64)
	if err != nil {
		return 0
	}
	return val
}

// checkSSHScript executes an allowlisted script on a remote target via SSH.
//
// Routing follows the canonical access model (internal/remote): an LXC or VM
// is NEVER SSH'd into directly — it is reached through its Proxmox host via
// `pct exec`/`qm guest exec`, so a guest needs no lan_ip, sshd, or authorized
// key of its own. Hosts and workstations are reached by direct SSH, resolved
// live so a workstation's login (mac-mini: `user: dtoro`) is honored without
// a re-seed. Services and other entities fall back to the host address baked
// into check config at seed time (their hosting container's address).
func checkSSHScript(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
	cfg := struct {
		Host   string `json:"host"`
		Port   int    `json:"port"`
		User   string `json:"user"`
		Script string `json:"script"`
		// Args is a single positional argument for the script. checkdefaults
		// has always written it for process_check.sh, but nothing read it —
		// so every process check ran argument-less and process_check.sh
		// answered "no service name provided" with health unknown.
		Args string `json:"args"`
	}{}
	if len(cd.Config) > 0 {
		_ = json.Unmarshal(cd.Config, &cfg)
	}
	if cfg.Host == "" || cfg.Script == "" {
		return checkResult{health: "healthy"}
	}

	if !allowlistedScript(cfg.Script) {
		return checkResult{
			health: "unknown", signalKind: "ssh-script",
			evidence: fmt.Sprintf("script %q not allowlisted", cfg.Script),
		}
	}

	timeout := time.Duration(cd.TimeoutS) * time.Second
	if timeout <= 0 {
		timeout = 10 * time.Second
	}

	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	scriptPath := "/opt/oikos/checks/" + cfg.Script
	if cfg.Args != "" {
		// Single-quote the argument so an entity name can never break out of
		// the remote command. The script name itself is allowlisted above.
		scriptPath += " '" + strings.ReplaceAll(cfg.Args, "'", `'\''`) + "'"
	}

	// Resolve the execution endpoint. The resolver handles every target kind:
	// LXC/VM host-hop via pct/qm exec; hosts/workstations direct at their own
	// address; services route through their hosting compute entity (via the
	// provides edge) so a service check reaches the right machine with the
	// right user instead of baking an LXC lan_ip and SSHing it as root.
	host, port, user := cfg.Host, strconv.Itoa(oru(cfg.Port, 22)), orStr(cfg.User, sshUser)
	wrap := func(cmd string) string { return cmd }
	targetType := ""
	if cd.TargetType != nil {
		targetType = *cd.TargetType
	}
	// target_type was omitted by older writeCheck inserts, so resolve it from
	// the target entity when the column is blank — otherwise the guest routing
	// below (IsGuest) never triggers and a guest check falls back to its baked
	// (often mesh-only) address.
	if targetType == "" && cd.TargetID != nil {
		if err := pool.QueryRow(ctx, "SELECT type FROM entities WHERE id = $1", *cd.TargetID).Scan(&targetType); err != nil {
			targetType = ""
		}
	}
	if cd.TargetID != nil && targetType != "" {
		et, err := remote.ResolveExecTargetForCheck(ctx, pool, *cd.TargetID, targetType, sshUser)
		if err != nil {
			if remote.IsGuest(targetType) {
				return checkResult{
					health: "down", signalKind: "ssh-script",
					evidence: fmt.Sprintf("route guest %s: %v", cd.EntitySlug, err), err: err,
				}
			}
			// Non-guest: log the resolution failure so an opaque ssh "down"
			// doesn't hide that the real cause was host/user resolution, then
			// fall back to the baked config below.
			slog.Warn("scheduler: target resolution failed, using baked config",
				"entity", cd.EntitySlug, "target_type", targetType, "error", err)
		} else {
			host, port, user, wrap = et.Host, "22", et.User, et.Wrap
		}
	}

	output, err := sshExec(ctx, host, port, user, wrap(scriptPath), timeout)
	if err != nil {
		return checkResult{
			health: "down", signalKind: "ssh-script",
			evidence: fmt.Sprintf("ssh %s:%s %s: %v", host, port, cfg.Script, err),
			err:      err,
		}
	}

	type scriptOutput struct {
		Health     string             `json:"health"`
		SignalKind string             `json:"signalKind"`
		Evidence   string             `json:"evidence"`
		Metrics    map[string]float64 `json:"metrics"`
	}
	var so scriptOutput
	if err := json.Unmarshal(output, &so); err != nil {
		return checkResult{
			health: "down", signalKind: "ssh-script",
			evidence: fmt.Sprintf("invalid script output from %s: %v", cfg.Script, err),
			err:      err,
		}
	}

	health := so.Health
	if health == "" {
		health = "healthy"
	}

	metrics := so.Metrics
	if metrics == nil {
		metrics = make(map[string]float64)
	}

	return checkResult{
		health:     health,
		signalKind: so.SignalKind,
		evidence:   so.Evidence,
		metrics:    metrics,
	}
}

// oru returns v when nonzero, else def. orStr returns v when non-empty, else def.
func oru(v, def int) int {
	if v != 0 {
		return v
	}
	return def
}
func orStr(v, def string) string {
	if v != "" {
		return v
	}
	return def
}

var scriptNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]+\.sh$`)

func allowlistedScript(name string) bool {
	return scriptNameRe.MatchString(name)
}

// sshExec runs a command on a remote host over crypto/ssh via the shared
// actuator primitives. It replaced a fork of `os/exec ssh` so the scheduler,
// the MCP execution path, and the actuator share one dial/run/host-key
// implementation (plan E3). The host key is verified through the centralized
// actuator.HostKeyCallback seam. ctx bounds the running command; timeout
// bounds the dial.
func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) {
	keyPath := sshKeyPath
	if keyPath == "" {
		// Preserve the old os/exec-ssh behavior of deferring to a default
		// key when no explicit OIKOS_SSH_KEY_PATH is configured: the system
		// ssh binary used the agent / ~/.ssh; crypto/ssh has no agent wiring,
		// so fall back to SSH_KEY_PATH then ~/.ssh/id_rsa.
		keyPath = os.Getenv("SSH_KEY_PATH")
		if keyPath == "" {
			keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
		}
	}
	signer, err := actuator.LoadSigner(keyPath)
	if err != nil {
		return nil, fmt.Errorf("ssh %s: %v", host, err)
	}
	p := 22
	if port != "" {
		if n, parseErr := strconv.Atoi(port); parseErr == nil && n > 0 {
			p = n
		}
	}
	client, err := sshPool.Get(ctx, actuator.DialOptions{
		Host: host, Port: p, User: user, Signer: signer, Timeout: timeout,
	})
	if err != nil {
		return nil, fmt.Errorf("ssh %s: %v", host, err)
	}
	// RunOutput (stdout-only) — the scheduler parses check output as JSON or
	// matches it literally, so stderr must not be merged in (RunCombinedOutput
	// is for the live-run display path in mcp/httpapi).
	out, err := actuator.RunOutput(ctx, client, cmd)
	if err != nil {
		return nil, fmt.Errorf("ssh %s: %v", host, err)
	}
	return out, nil
}

// metricThreshold defines warn/crit thresholds for a single metric.
type metricThreshold struct {
	Warn float64 `json:"warn"`
	Crit float64 `json:"crit"`
}

// thresholdsConfig is parsed from check_defs.config.thresholds JSONB.
type thresholdsConfig map[string]metricThreshold

// evaluateSeverity determines signal severity from check result and thresholds.
func evaluateSeverity(kind string, signalKind string, config []byte, metrics map[string]float64) string {
	var thresholds thresholdsConfig
	if len(config) > 0 {
		_ = json.Unmarshal(config, &thresholds)
	}

	for metric, value := range metrics {
		t, ok := thresholds[metric]
		if !ok {
			continue
		}
		if t.Crit > 0 && value >= t.Crit {
			return "critical"
		}
		if t.Warn > 0 && value >= t.Warn {
			return "warning"
		}
	}

	if signalKind == "down" {
		return "critical"
	}
	return "warning"
}

var _ = uuid.UUID{} // ensure uuid import stays
