package dispatch

import (
	"context"
	"strings"
	"time"

	"google.golang.org/protobuf/types/known/timestamppb"

	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// Auto-provision a freshly-generated clustered 7DTD world (zero-touch create),
// AND keep a visible "setting up" status until it is actually done so an operator
// who refreshes the dashboard sees it is not ready yet (don't restart it).
//
// A new cluster world bakes its decorations (decoration.7dt / multiblocks.7dt)
// with the engine's NATIVE block ids at generation. The entrypoint stamps the
// cluster CANONICAL .nim over the world's table every boot so shared inventory
// (.ttp) decodes uniformly — but native-baked decorations then resolve to a null
// Block under canonical -> DecoManager NRE -> the world loads but never reaches
// GameStartDone ("online but unjoinable"). The entrypoint fixes this by remapping
// the decorations native->canonical on the FIRST boot it sees them in the native
// band — which can only be a boot AFTER the gen boot (the running server writes
// decoration.7dt; the entrypoint can't see it until next start).
//
// This watcher (a) closes that 2-phase gap so create is zero-touch — it bounces
// the container ONCE so the entrypoint's remap runs — and (b) holds a persistent
// "Finishing cluster setup…" status detail (the dashboard renders it as a "setting
// up" pill instead of a green "running" one) until the world carries the
// .canon-provisioned marker, so a page refresh always shows it isn't done yet.
//
// Strict no-op for non-cluster servers (no /cluster bind -> the watcher exits) and
// already-canon worlds (deco in canon band -> "CANON" on the first probe -> the
// indicator clears immediately). Single-flight; the entrypoint is the backstop if
// the watcher ever misses (the world still provisions on its next restart).
// Mirrors the hang-guard single-flight container-restart pattern (hangguard.go).

const (
	provisionPollInterval = 20 * time.Second
	provisionPollTimeout  = 15 * time.Minute
	provisionExecTimeout  = 20 * time.Second
	provisionRestartGrace = 30 * time.Second
)

// provisionDetail is the persistent status detail shown while a clustered world is
// still aligning to the cluster canonical. The dashboard matches this text to paint
// a "setting up" pill, so a refresh shows the server isn't ready yet.
const provisionDetail = "Finishing cluster setup — aligning decorations to the cluster (it auto-restarts once); please wait, don't restart it."

// provisionProbeScript runs inside the container and classifies the active cluster
// world's provisioning state. Emits exactly one token on stdout:
//
//	NOWORLD     no recorded save dir (RWG world not yet picked in the Cluster tab)
//	PROVISIONED .canon-provisioned marker present (already aligned)
//	GENERATING  no main.ttw yet (world still generating)
//	CANON       decorations already in the canon band (>=24000) -> nothing to do
//	ATTEMPTED   already bounced once and still not aligned -> stop (avoid a loop)
//	NEEDS_ALIGN gen done; decorations native-banded OR not baked yet -> bounce once
//
// Gated on main.ttw (= gen finished) not on decoration.7dt existing, because some
// worlds only write decoration.7dt on the first save — the bounce's graceful save
// writes it (native), then the entrypoint remaps it on the align boot. First deco
// block-id = u16 @ offset 17 (hdr 5 + record id-offset 12), masked to 15 bits.
const provisionProbeScript = `SB="$(cat /game-saves/.panel-save-base 2>/dev/null)"; ` +
	`[ -z "$SB" ] && { echo NOWORLD; exit 0; }; ` +
	`[ -f "$SB/.canon-provisioned" ] && { echo PROVISIONED; exit 0; }; ` +
	`[ -f "$SB/main.ttw" ] || { echo GENERATING; exit 0; }; ` +
	`D="$SB/decoration.7dt"; ` +
	`if [ -f "$D" ]; then raw="$(od -An -tu2 -j17 -N2 "$D" 2>/dev/null | tr -d ' ')"; id=$(( ${raw:-0} & 0x7FFF )); [ "$id" -ge 24000 ] && { echo CANON; exit 0; }; fi; ` +
	`[ -f "$SB/.panel-provision-attempted" ] && { echo ATTEMPTED; exit 0; }; ` +
	`echo NEEDS_ALIGN`

// provisionAttemptScript drops a durable one-bounce guard so a world that doesn't
// align after its single bounce is never bounced again (the entrypoint remains the
// backstop on ordinary restarts).
const provisionAttemptScript = `SB="$(cat /game-saves/.panel-save-base 2>/dev/null)"; ` +
	`[ -n "$SB" ] && touch "$SB/.panel-provision-attempted" 2>/dev/null; true`

// autoProvisionClusterWorldOnStart starts (single-flight) the provision watcher for
// a just-started 7DTD instance. Returns immediately; the watch runs off a background
// goroutine bounded by provisionPollTimeout.
func (d *Dispatcher) autoProvisionClusterWorldOnStart(rec *instanceRecord) {
	if rec.ModuleID != "7dtd" {
		return
	}
	if !rec.provisionGuard.CompareAndSwap(false, true) {
		return // a watcher is already running for this instance
	}
	go func() {
		defer rec.provisionGuard.Store(false)
		log := d.log.With("instance_id", rec.InstanceID, "auto_provision", true)

		// Only clustered worlds provision (and show the indicator). Resolve the
		// /cluster bind like clusterplayer.go; bail out for standalone servers.
		mctx, mcancel := context.WithTimeout(context.Background(), 15*time.Second)
		mounts, _ := d.runtime.ContainerMounts(mctx, "panel-"+rec.InstanceID)
		mcancel()
		clustered := false
		for _, m := range mounts {
			if m.Destination == "/cluster" && m.Type == "bind" && m.Source != "" {
				clustered = true
				break
			}
		}
		if !clustered {
			return
		}

		deadline := time.Now().Add(provisionPollTimeout)
		delay := 5 * time.Second // first poll soon, to override a premature green "running"
		var lastDetail string
		bounced := false
		emit := func(detail string) {
			if detail == lastDetail {
				return
			}
			lastDetail = detail
			d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0, detail)
		}
		for {
			time.Sleep(delay)
			delay = provisionPollInterval
			if time.Now().After(deadline) {
				return // gave up; the entrypoint aligns on the next ordinary restart
			}
			ectx, cancel := context.WithTimeout(context.Background(), provisionExecTimeout)
			stdout, _, code, err := d.runtime.ExecCapture(ectx, rec.ContainerID, []string{"sh", "-c", provisionProbeScript})
			cancel()
			if err != nil || code != 0 {
				continue // mid-boot / exec hiccup — leave the agent's own status alone
			}
			switch strings.TrimSpace(string(stdout)) {
			case "CANON", "PROVISIONED":
				emit("running") // fully set up — clear the "setting up" indicator
				return
			case "NEEDS_ALIGN":
				emit(provisionDetail)
				if !bounced {
					bounced = true
					actx, acancel := context.WithTimeout(context.Background(), provisionExecTimeout)
					_, _, _, _ = d.runtime.ExecCapture(actx, rec.ContainerID, []string{"sh", "-c", provisionAttemptScript})
					acancel()
					log.Info("auto-provision: aligning new cluster world to canonical (one-time bounce)")
					d.sendEnv(&panelv1.AgentEnvelope{
						SentAt: timestamppb.Now(),
						Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
							InstanceId: rec.InstanceID, Stream: "stdout", At: timestamppb.Now(),
							Line: "[panel] auto-provision: aligning the new world's decorations to the cluster (one-time restart)…",
						}},
					})
					rctx, rcancel := context.WithTimeout(context.Background(), 90*time.Second)
					if err := d.runtime.Restart(rctx, rec.ContainerID, provisionRestartGrace); err != nil {
						log.Error("auto-provision restart failed (entrypoint aligns on the next ordinary restart)", "err", err)
					}
					rcancel()
				}
				continue // keep watching across the bounce until the align boot marks it provisioned
			default: // GENERATING / NOWORLD / ATTEMPTED — still setting up
				emit(provisionDetail)
				continue
			}
		}
	}()
}
