package runtime

import (
	"archive/tar"
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/build"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/api/types/mount"
	"github.com/docker/docker/api/types/network"
	"github.com/docker/docker/api/types/volume"
	"github.com/docker/docker/client"
	"github.com/docker/docker/pkg/stdcopy"
	"github.com/moby/go-archive"
	"github.com/docker/go-connections/nat"
)

// DockerRuntime launches instances as Docker containers on the local daemon.
// Container IDs (the string returned by Create) are Docker's full container IDs.
type DockerRuntime struct {
	cli *client.Client
	// hostNetUnsupported is true when the daemon is Docker Desktop
	// (Windows/macOS), where NetworkMode=host binds inside the hidden
	// Linux VM and is NOT reachable from the host LAN. On such daemons we
	// fall back to bridge networking + published ports so the panel's
	// allocated ports actually reach players. Detected once at startup;
	// overridable via PANEL_FORCE_BRIDGE=1 / PANEL_FORCE_HOST_NET=1.
	hostNetUnsupported bool
}

// NewDocker connects to the local Docker daemon using environment-configured
// settings (DOCKER_HOST, DOCKER_CERT_PATH, …) and negotiates an API version.
func NewDocker() (*DockerRuntime, error) {
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		return nil, fmt.Errorf("docker client: %w", err)
	}
	r := &DockerRuntime{cli: cli}
	r.hostNetUnsupported = detectHostNetUnsupported(cli)
	return r, nil
}

// applyHostNetPolicy decides the effective network mode + port plumbing for a
// container. Native Linux keeps host mode and drops the (meaningless) port
// bindings; a Docker-Desktop daemon converts host mode to bridge and KEEPS the
// published bindings so the panel-allocated ports reach the host LAN. Non-host
// modes pass through unchanged. Extracted for testability.
func applyHostNetPolicy(r *DockerRuntime, requested string, exposed nat.PortSet, bindings nat.PortMap) (string, nat.PortSet, nat.PortMap) {
	if requested != "host" {
		return requested, exposed, bindings
	}
	if r.hostNetUnsupported {
		return "", exposed, bindings // bridge + published ports
	}
	return "host", nil, nil // native host mode, no bindings
}

// detectHostNetUnsupported returns true when NetworkMode=host won't reach the
// host LAN — i.e. Docker Desktop's Linux VM on Windows/macOS. Explicit env
// overrides win over autodetection so operators can force either behavior.
func detectHostNetUnsupported(cli *client.Client) bool {
	if os.Getenv("PANEL_FORCE_BRIDGE") == "1" {
		return true
	}
	if os.Getenv("PANEL_FORCE_HOST_NET") == "1" {
		return false
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	info, err := cli.Info(ctx)
	if err != nil {
		return false // can't tell → assume native Linux (host works)
	}
	// Docker Desktop reports OperatingSystem "Docker Desktop" and, on
	// Windows hosts, OSType "linux" while the actual client OS is windows.
	// The tell-tale is the "Docker Desktop" marker or a non-Linux daemon.
	if strings.Contains(info.OperatingSystem, "Docker Desktop") {
		return true
	}
	if info.OSType != "" && info.OSType != "linux" {
		return true
	}
	return false
}

// Close releases daemon resources.
func (r *DockerRuntime) Close() error {
	if r.cli == nil {
		return nil
	}
	return r.cli.Close()
}

// Name identifies the runtime kind.
func (r *DockerRuntime) Name() string { return "docker" }

// Ping verifies we can reach the Docker daemon.
func (r *DockerRuntime) Ping(ctx context.Context) error {
	_, err := r.cli.Ping(ctx)
	return err
}

// Create pulls the image (if missing locally) and creates the container.
// The container is NOT started — call Start afterward.
func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string, error) {
	if spec.InstanceID == "" {
		return "", errors.New("spec.InstanceID is required")
	}
	if spec.Image == "" {
		return "", errors.New("spec.Image is required")
	}

	if err := r.acquireImage(ctx, spec.Image, spec.BuildContext, spec.LogSink); err != nil {
		return "", err
	}

	exposed, bindings, err := buildPortBindings(spec.Ports)
	if err != nil {
		return "", err
	}
	// Docker rejects PortBindings + ExposedPorts when NetworkMode=host —
	// the container shares the host's network namespace so publish-port
	// plumbing is meaningless there.
	var netMode string
	netMode, exposed, bindings = applyHostNetPolicy(r, spec.NetworkMode, exposed, bindings)
	if spec.NetworkMode == "host" && netMode != "host" && len(bindings) > 0 && spec.LogSink != nil {
		spec.LogSink("Docker Desktop detected: publishing ports via bridge instead of host networking (host mode is not LAN-reachable on Docker Desktop)")
	}

	envSlice := make([]string, 0, len(spec.Env))
	for k, v := range spec.Env {
		envSlice = append(envSlice, k+"="+v)
	}

	mounts := make([]mount.Mount, 0, len(spec.Volumes))
	for _, v := range spec.Volumes {
		m := mount.Mount{
			Target:   v.ContainerPath,
			ReadOnly: v.ReadOnly,
		}
		switch v.Type {
		case "volume":
			if v.VolumeName == "" {
				return "", fmt.Errorf("volume mount %q: Name required", v.ContainerPath)
			}
			if err := r.ensureVolume(ctx, v.VolumeName, spec.InstanceID); err != nil {
				return "", err
			}
			m.Type = mount.TypeVolume
			m.Source = v.VolumeName
		default: // "" or "bind"
			if v.HostPath == "" {
				return "", fmt.Errorf("bind mount %q: HostPath required", v.ContainerPath)
			}
			// Docker CLI auto-creates missing bind sources, but the Go API
			// does not — it errors with "bind source path does not exist"
			// and refuses to start the container. Modules declaring subdir
			// binds like `$DATA_PATH/logs` rely on the source existing.
			// Two cases:
			//   - Path exists as a file (panel-rendered config like
			//     serverconfig.xml.rendered): leave it alone, it's a
			//     file bind.
			//   - Path missing or already a directory: mkdir -p so docker
			//     bind-mount has something to mount.
			if st, err := os.Stat(v.HostPath); err == nil && !st.IsDir() {
				// existing file → file bind, no mkdir
			} else if err := os.MkdirAll(v.HostPath, 0o755); err != nil {
				return "", fmt.Errorf("bind mount %q: mkdir source %q: %w", v.ContainerPath, v.HostPath, err)
			}
			m.Type = mount.TypeBind
			m.Source = v.HostPath
		}
		mounts = append(mounts, m)
	}

	restart := spec.RestartPolicy
	if restart == "" {
		restart = "unless-stopped"
	}

	hostCfg := &container.HostConfig{
		Mounts:       mounts,
		PortBindings: bindings,
		NetworkMode:  container.NetworkMode(netMode),
		SecurityOpt:  spec.SecurityOpts,
		RestartPolicy: container.RestartPolicy{
			Name: container.RestartPolicyMode(restart),
		},
		Resources: container.Resources{
			CPUShares: int64(spec.Resources.CPUShares),
			Memory:    int64(spec.Resources.MemoryBytes),
			NanoCPUs:  spec.Resources.NanoCPUs,
			PidsLimit: pidsPtr(spec.Resources.PidsLimit),
		},
	}

	cfg := &container.Config{
		Image:        spec.Image,
		Env:          envSlice,
		Entrypoint:   spec.Entrypoint,
		Cmd:          spec.Command,
		User:         spec.User,
		WorkingDir:   spec.WorkingDir,
		ExposedPorts: exposed,
		Tty:          false,
		// OpenStdin + StdinOnce=false = stdin stays open across reconnects.
		// The stdio RCON adapter attaches to stdin later via ContainerAttach.
		OpenStdin: spec.OpenStdin,
		StdinOnce: false,
		Labels: map[string]string{
			"panel.instance_id": spec.InstanceID,
			"panel.role":        roleLabel(spec.IsSidecar),
		},
	}

	name := "panel-" + spec.InstanceID
	resp, err := r.cli.ContainerCreate(ctx, cfg, hostCfg, &network.NetworkingConfig{}, nil, name)
	if err != nil {
		return "", fmt.Errorf("container create: %w", err)
	}
	return resp.ID, nil
}

// Start starts a previously-created container.
func (r *DockerRuntime) Start(ctx context.Context, id string) error {
	if err := r.cli.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
		return fmt.Errorf("container start: %w", err)
	}
	return nil
}

// Stop stops the container, waiting up to grace before SIGKILL.
func (r *DockerRuntime) Stop(ctx context.Context, id string, grace time.Duration) error {
	secs := int(grace.Seconds())
	if secs < 0 {
		secs = 0
	}
	opts := container.StopOptions{Timeout: &secs}
	if err := r.cli.ContainerStop(ctx, id, opts); err != nil {
		return fmt.Errorf("container stop: %w", err)
	}
	return nil
}

// Restart restarts the container. Same StopOptions semantics as Stop —
// engine waits up to grace seconds before SIGKILL, then the container is
// started fresh. The container's RestartPolicy is irrelevant here;
// ContainerRestart always brings it back regardless of policy.
func (r *DockerRuntime) Restart(ctx context.Context, id string, grace time.Duration) error {
	secs := int(grace.Seconds())
	if secs < 0 {
		secs = 0
	}
	opts := container.StopOptions{Timeout: &secs}
	if err := r.cli.ContainerRestart(ctx, id, opts); err != nil {
		return fmt.Errorf("container restart: %w", err)
	}
	return nil
}

// Remove removes the container. Force=true kills if still running.
func (r *DockerRuntime) Remove(ctx context.Context, id string) error {
	err := r.cli.ContainerRemove(ctx, id, container.RemoveOptions{
		Force:         true,
		RemoveVolumes: false,
	})
	if err != nil && !client.IsErrNotFound(err) {
		return fmt.Errorf("container remove: %w", err)
	}
	return nil
}

// StreamLogs follows stdout+stderr and invokes handler for each line.
// Blocks until ctx is cancelled, the container exits, or the connection errors.
func (r *DockerRuntime) StreamLogs(ctx context.Context, id string, handler LogHandler) error {
	rc, err := r.cli.ContainerLogs(ctx, id, container.LogsOptions{
		ShowStdout: true,
		ShowStderr: true,
		Follow:     true,
		// Timestamps:true prefixes each line with docker's own RFC3339Nano
		// time. We parse that prefix below and pass it as the line's `at`
		// instead of time.Now(). Critical for the readiness gate: the
		// docker log file persists across container restarts, so a Tail:400
		// replay can include the PREVIOUS run's "Server has completed
		// startup" line. Without docker's real timestamp on each line, the
		// frontend can't tell stale replays from current-run lines and
		// the pill flips to "running" the instant the buffer is fetched.
		Timestamps: true,
		// Start with a little backlog so the console isn't blank if someone
		// opens the modal mid-run. 400 lines is ~5s of chatter at the busiest
		// and is trivial to stream.
		Tail: "400",
	})
	if err != nil {
		return fmt.Errorf("container logs: %w", err)
	}
	defer rc.Close()

	outPr, outPw := io.Pipe()
	errPr, errPw := io.Pipe()
	defer outPw.Close()
	defer errPw.Close()

	done := make(chan error, 3)

	// Demux multiplexed docker log stream into separate stdout/stderr pipes.
	go func() {
		_, err := stdcopy.StdCopy(outPw, errPw, rc)
		_ = outPw.Close()
		_ = errPw.Close()
		done <- err
	}()

	scan := func(r io.Reader, stream LogStream) {
		sc := bufio.NewScanner(r)
		sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
		// Split on BOTH \n and \r so terminal-style progress updates
		// (SteamCMD "Update state ... progress: X%" uses \r to overwrite
		// the same line) show up as they happen instead of buffering
		// until the next real newline minutes later.
		sc.Split(scanLogLine)
		for sc.Scan() {
			raw := stripANSI(sc.Text())
			if raw == "" {
				continue
			}
			// Strip docker's RFC3339Nano timestamp prefix and use it as
			// the line's authoritative time. Format is exact:
			//   "2026-04-29T17:46:53.123456789Z <line content>"
			// On parse failure we fall back to time.Now() so a malformed
			// line still gets streamed (better wrong timestamp than
			// dropped log line).
			line, ts := splitDockerTimestamp(raw)
			handler(stream, line, ts)
		}
		done <- sc.Err()
	}
	go scan(outPr, LogStreamStdout)
	go scan(errPr, LogStreamStderr)

	for i := 0; i < 3; i++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case err := <-done:
			if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
				return err
			}
		}
	}
	return nil
}

// ListVolumesByInstance returns every volume labeled panel.instance_id=id.
// Used by the delete-with-purge path to clean up the instance's storage.
func (r *DockerRuntime) ListVolumesByInstance(ctx context.Context, instanceID string) ([]string, error) {
	lst, err := r.cli.VolumeList(ctx, volume.ListOptions{})
	if err != nil {
		return nil, fmt.Errorf("volume list: %w", err)
	}
	var names []string
	for _, v := range lst.Volumes {
		if v == nil {
			continue
		}
		if v.Labels["panel.instance_id"] == instanceID {
			names = append(names, v.Name)
		}
	}
	return names, nil
}

// RemoveVolume removes a Docker volume by name. force=true removes even
// if in-use references exist (shouldn't happen for us since we remove
// containers first).
func (r *DockerRuntime) RemoveVolume(ctx context.Context, name string, force bool) error {
	if err := r.cli.VolumeRemove(ctx, name, force); err != nil {
		return fmt.Errorf("volume remove %q: %w", name, err)
	}
	return nil
}

// ListContainersHoldingInstanceVolumes returns container IDs for any
// container (running OR exited) whose mount list references one of
// the panel volumes labeled for the given instance.
//
// Used by the delete path to sweep stranded sidecars — exited steamcmd
// or backup containers that the agent crashed before removing.
// They keep volumes "in use" for `docker volume rm` purposes even when
// they're not running, blocking instance delete-with-purge forever.
//
// We list ALL containers (including exited) and filter by mount name
// rather than relying on the container's own panel label, because
// updater sidecars don't carry the instance label — they're spawned
// by the alpine helper which doesn't know about panel.* labels.
func (r *DockerRuntime) ListContainersHoldingInstanceVolumes(ctx context.Context, instanceID string) ([]string, error) {
	// Build the set of volume names this instance owns.
	vols, err := r.ListVolumesByInstance(ctx, instanceID)
	if err != nil {
		return nil, err
	}
	if len(vols) == 0 {
		return nil, nil
	}
	wanted := make(map[string]bool, len(vols))
	for _, v := range vols {
		wanted[v] = true
	}
	// Cheap heuristic: panel-<id>- prefix on the container name catches
	// most sidecars (panel-X-steamcmd-N, panel-X-fs, panel-X-backup).
	// We still cross-check via mounts so renamed/old containers don't
	// slip through.
	prefix := "panel-" + instanceID + "-"

	cs, err := r.cli.ContainerList(ctx, container.ListOptions{All: true})
	if err != nil {
		return nil, fmt.Errorf("container list: %w", err)
	}
	out := make([]string, 0, len(cs))
	for _, c := range cs {
		// Match by mount.
		matched := false
		for _, m := range c.Mounts {
			if m.Type == mount.TypeVolume && wanted[m.Name] {
				matched = true
				break
			}
		}
		// Or by name prefix.
		if !matched {
			for _, n := range c.Names {
				name := strings.TrimPrefix(n, "/")
				if strings.HasPrefix(name, prefix) {
					matched = true
					break
				}
			}
		}
		if matched {
			out = append(out, c.ID)
		}
	}
	return out, nil
}

// roleLabel maps the sidecar flag to the "panel.role" label value.
func roleLabel(isSidecar bool) string {
	if isSidecar {
		return "sidecar"
	}
	return "instance"
}

// ListMainInstanceNames returns the instance IDs of every main
// game-server container (panel.role=instance), excluding the transient
// fs / steamcmd / backup / etc. helper sidecars. Used by Rehydrate to
// detect orphans: a main container with no matching sidecar metadata,
// which would otherwise be silently invisible to the agent.
//
// Filtering is by the "panel.role" label, set at create time, not by
// name-suffix heuristics — so new sidecar kinds can't be misclassified
// as instances. Containers predating the label (no panel.role) fall
// back to a name check: anything matching a known sidecar suffix is
// excluded, everything else under the panel- prefix is treated as a
// main instance (the conservative choice — better to surface a stray
// helper as a candidate orphan than to hide a real instance).
func (r *DockerRuntime) ListMainInstanceNames(ctx context.Context) ([]string, error) {
	cs, err := r.cli.ContainerList(ctx, container.ListOptions{All: true})
	if err != nil {
		return nil, fmt.Errorf("container list: %w", err)
	}
	out := make([]string, 0, len(cs))
	for _, c := range cs {
		// Prefer the explicit label.
		if role, ok := c.Labels["panel.role"]; ok {
			if role != "instance" {
				continue
			}
			if id := c.Labels["panel.instance_id"]; id != "" {
				out = append(out, id)
			}
			continue
		}
		// Legacy fallback for containers created before panel.role existed.
		for _, n := range c.Names {
			name := strings.TrimPrefix(n, "/")
			id, ok := strings.CutPrefix(name, "panel-")
			if !ok || legacyIsSidecarName(id) {
				continue
			}
			out = append(out, id)
		}
	}
	return out, nil
}

// legacyIsSidecarName classifies pre-"panel.role" containers by their
// reserved trailing role. Instance ids themselves can contain hyphens
// (e.g. "scorched-earth"), so we match known suffixes rather than
// splitting on "-".
func legacyIsSidecarName(afterPrefix string) bool {
	for _, suf := range []string{"-fs", "-backup", "-restore", "-wipe", "-basemods", "-warmseed"} {
		if strings.HasSuffix(afterPrefix, suf) {
			return true
		}
	}
	// steamcmd sidecars are panel-<id>-steamcmd-<n>.
	if strings.Contains(afterPrefix, "-steamcmd-") || strings.HasSuffix(afterPrefix, "-steamcmd") {
		return true
	}
	return false
}

// ForceRemoveContainer is `docker rm -f` — kills a running container
// and removes it. Used by the orphan-sidecar sweep on delete; the
// sidecar's exit-code state doesn't matter since we're nuking it.
func (r *DockerRuntime) ForceRemoveContainer(ctx context.Context, containerID string) error {
	return r.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
		Force:         true,
		RemoveVolumes: false, // we'll remove volumes separately
	})
}

// ensureVolume creates the named volume if it doesn't exist. Labels it so
// operators can identify panel-owned volumes (`docker volume ls -f
// label=panel.instance_id=ark-test`).
func (r *DockerRuntime) ensureVolume(ctx context.Context, name, instanceID string) error {
	_, err := r.cli.VolumeInspect(ctx, name)
	if err == nil {
		return nil
	}
	if !client.IsErrNotFound(err) {
		return fmt.Errorf("inspect volume %q: %w", name, err)
	}
	_, err = r.cli.VolumeCreate(ctx, volume.CreateOptions{
		Name:   name,
		Driver: "local",
		Labels: map[string]string{
			"panel.instance_id": instanceID,
		},
	})
	if err != nil {
		return fmt.Errorf("create volume %q: %w", name, err)
	}
	return nil
}

// InspectByName looks up a container by its Docker name (leading "/"
// stripped by the API) and returns its state. Used by the Agent on cold
// start to check whether a sidecar-metadata file still points at a live
// container.
func (r *DockerRuntime) InspectByName(ctx context.Context, name string) (RuntimeInstanceState, error) {
	c, err := r.cli.ContainerInspect(ctx, name)
	if err != nil {
		return RuntimeInstanceState{}, err
	}
	st := RuntimeInstanceState{ContainerID: c.ID}
	if c.State != nil {
		st.Status = c.State.Status
		st.ExitCode = int32(c.State.ExitCode)
	}
	return st, nil
}

// ContainerMounts returns the mount points of a container by name. Works
// on stopped containers. Used to resolve a 7DTD instance's /cluster bind
// to its host path for the cluster player-save snapshotter.
func (r *DockerRuntime) ContainerMounts(ctx context.Context, name string) ([]MountInfo, error) {
	c, err := r.cli.ContainerInspect(ctx, name)
	if err != nil {
		return nil, err
	}
	out := make([]MountInfo, 0, len(c.Mounts))
	for _, m := range c.Mounts {
		out = append(out, MountInfo{
			Type:        string(m.Type),
			Source:      m.Source,
			Destination: m.Destination,
			Name:        m.Name,
		})
	}
	return out, nil
}

// ContainerExists reports definitive existence vs. an indeterminate
// query failure. A not-found error from the daemon means the container
// is gone (exists=false, err=nil); any other error (daemon unreachable,
// connection dropped mid-call during agent shutdown, ctx cancelled)
// leaves existence UNKNOWN and is returned verbatim so callers don't
// mistake "couldn't ask" for "doesn't exist".
func (r *DockerRuntime) ContainerExists(ctx context.Context, name string) (bool, error) {
	_, err := r.cli.ContainerInspect(ctx, name)
	if err == nil {
		return true, nil
	}
	if client.IsErrNotFound(err) {
		return false, nil
	}
	return false, err
}

// StatsStream opens Docker's ContainerStats (streaming) and returns the
// raw JSON reader. Each line is a stats frame — caller parses + throttles.
func (r *DockerRuntime) StatsStream(ctx context.Context, id string) (io.ReadCloser, error) {
	resp, err := r.cli.ContainerStats(ctx, id, true)
	if err != nil {
		return nil, fmt.Errorf("container stats: %w", err)
	}
	return resp.Body, nil
}

// Wait blocks until the container exits and returns its exit code.
func (r *DockerRuntime) Wait(ctx context.Context, id string) (int, error) {
	statusCh, errCh := r.cli.ContainerWait(ctx, id, container.WaitConditionNotRunning)
	select {
	case <-ctx.Done():
		return -1, ctx.Err()
	case err := <-errCh:
		if err != nil {
			return -1, fmt.Errorf("container wait: %w", err)
		}
		return -1, nil
	case s := <-statusCh:
		if s.Error != nil {
			return int(s.StatusCode), fmt.Errorf("wait: %s", s.Error.Message)
		}
		return int(s.StatusCode), nil
	}
}

// ---- Container-path file ops (used by the panel's file manager) ----

// ExecCapture runs cmd inside the given running container and returns its
// captured stdout + stderr + exit code.
func (r *DockerRuntime) ExecCapture(ctx context.Context, id string, cmd []string) ([]byte, []byte, int, error) {
	resp, err := r.cli.ContainerExecCreate(ctx, id, container.ExecOptions{
		Cmd:          cmd,
		AttachStdout: true,
		AttachStderr: true,
	})
	if err != nil {
		return nil, nil, -1, fmt.Errorf("exec create: %w", err)
	}
	att, err := r.cli.ContainerExecAttach(ctx, resp.ID, container.ExecStartOptions{})
	if err != nil {
		return nil, nil, -1, fmt.Errorf("exec attach: %w", err)
	}
	defer att.Close()

	// StdCopy reads the hijacked exec stream, which is NOT bound by ctx — a
	// container caught mid-restart can leave this Read blocked forever, leaking
	// the underlying docker connection. Unbounded, that once cascaded into every
	// runtime op hanging (config file-reads 504'd; a log stream stopped
	// re-attaching). Run StdCopy off a goroutine and enforce ctx ourselves: on
	// timeout, Close() the attach to unblock the Read, then drain the goroutine
	// so the buffers are race-free to read.
	var stdout, stderr bytes.Buffer
	copyDone := make(chan error, 1)
	go func() { _, e := stdcopy.StdCopy(&stdout, &stderr, att.Reader); copyDone <- e }()
	select {
	case <-ctx.Done():
		att.Close() // unblock the goroutine's Read (defer Close is a safe no-op double-close)
		<-copyDone
		return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec copy: %w", ctx.Err())
	case err := <-copyDone:
		if err != nil && !errors.Is(err, io.EOF) {
			return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec copy: %w", err)
		}
	}
	insp, err := r.cli.ContainerExecInspect(ctx, resp.ID)
	if err != nil {
		return stdout.Bytes(), stderr.Bytes(), -1, fmt.Errorf("exec inspect: %w", err)
	}
	return stdout.Bytes(), stderr.Bytes(), insp.ExitCode, nil
}

// CopyFileFromContainer returns the raw bytes of the file at containerPath.
// Works on both running and stopped containers. Follows symlinks (Docker's
// archive API tars a symlink as a symlink without dereferencing — needed
// for cases like the 7DTD module's /game/serverconfig.xml -> /game-saves/…).
func (r *DockerRuntime) CopyFileFromContainer(ctx context.Context, id, containerPath string) ([]byte, error) {
	return r.copyFileFromContainerHop(ctx, id, containerPath, 0)
}

// maxCopyFileSymlinkHops bounds symlink resolution depth so a malicious or
// accidental symlink loop can't pin an agent goroutine. 8 is way more than
// any legitimate chain we've seen; bump it if a real use-case demands.
const maxCopyFileSymlinkHops = 8

func (r *DockerRuntime) copyFileFromContainerHop(ctx context.Context, id, containerPath string, depth int) ([]byte, error) {
	if depth >= maxCopyFileSymlinkHops {
		return nil, fmt.Errorf("symlink chain too deep (>%d hops) at %s", maxCopyFileSymlinkHops, containerPath)
	}
	rc, _, err := r.cli.CopyFromContainer(ctx, id, containerPath)
	if err != nil {
		return nil, fmt.Errorf("copy from container: %w", err)
	}
	defer rc.Close()
	tr := tar.NewReader(rc)
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			return nil, fmt.Errorf("file not found in archive")
		}
		if err != nil {
			return nil, fmt.Errorf("tar next: %w", err)
		}
		switch hdr.Typeflag {
		case tar.TypeReg, tar.TypeRegA:
			return io.ReadAll(tr)
		case tar.TypeDir:
			return nil, fmt.Errorf("path is a directory")
		case tar.TypeSymlink, tar.TypeLink:
			// Resolve the target. Docker hands back the symlink itself —
			// it never dereferences. Linkname can be absolute or relative;
			// relative is resolved against the link's containing dir, not
			// our cwd, mirroring how the kernel resolves it inside the FS.
			target := hdr.Linkname
			if !path.IsAbs(target) {
				target = path.Join(path.Dir(containerPath), target)
			}
			return r.copyFileFromContainerHop(ctx, id, target, depth+1)
		}
	}
}

// AttachStdio attaches to the container's multiplexed stdin/stdout/stderr
// stream and returns a single ReadWriteCloser. Writes go to stdin; reads
// are the raw multiplexed frame stream (our stdio RCON adapter drains and
// discards the reader — responses reach the panel via docker logs instead,
// so we don't need to demux here).
func (r *DockerRuntime) AttachStdio(ctx context.Context, id string) (io.ReadWriteCloser, error) {
	resp, err := r.cli.ContainerAttach(ctx, id, container.AttachOptions{
		Stream: true,
		Stdin:  true,
		Stdout: true,
		Stderr: true,
	})
	if err != nil {
		return nil, fmt.Errorf("container attach: %w", err)
	}
	return &hijackedStream{resp: resp}, nil
}

// hijackedStream adapts Docker's HijackedResponse (a bidirectional net.Conn
// plus a buffered reader) to io.ReadWriteCloser so the stdio adapter can
// treat it uniformly.
type hijackedStream struct {
	resp types.HijackedResponse
	mu   sync.Mutex
}

func (h *hijackedStream) Write(p []byte) (int, error) {
	return h.resp.Conn.Write(p)
}
func (h *hijackedStream) Read(p []byte) (int, error) {
	return h.resp.Reader.Read(p)
}
func (h *hijackedStream) Close() error {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.resp.Close()
	return nil
}

// CopyFileToContainer writes content to containerPath inside the container.
// Parent dir must exist (Docker's archive API doesn't create them).
func (r *DockerRuntime) CopyFileToContainer(ctx context.Context, id, containerPath string, content []byte) error {
	dir := path.Dir(containerPath)
	base := path.Base(containerPath)

	var buf bytes.Buffer
	tw := tar.NewWriter(&buf)
	if err := tw.WriteHeader(&tar.Header{
		Name: base,
		Mode: 0o644,
		Size: int64(len(content)),
	}); err != nil {
		return fmt.Errorf("tar header: %w", err)
	}
	if _, err := tw.Write(content); err != nil {
		return fmt.Errorf("tar body: %w", err)
	}
	if err := tw.Close(); err != nil {
		return fmt.Errorf("tar close: %w", err)
	}

	return r.cli.CopyToContainer(ctx, id, dir, &buf, container.CopyToContainerOptions{AllowOverwriteDirWithFile: false})
}

// CopyTarToContainer writes a raw tar stream onto dstDir. Caller is
// responsible for the tar formatting (one Header+body per entry, then
// Close()). Docker extracts in place. Used by the file manager's
// extract endpoint so we can ship a whole archive's contents in one
// API call instead of N per-file CopyFileToContainer round-trips.
func (r *DockerRuntime) CopyTarToContainer(ctx context.Context, id, dstDir string, tarStream io.Reader) error {
	return r.cli.CopyToContainer(ctx, id, dstDir, tarStream, container.CopyToContainerOptions{AllowOverwriteDirWithFile: true})
}

// CopyDirFromContainer returns a tar stream rooted at containerPath.
// Caller must Close() the reader. Used by the file manager's compress
// endpoint to walk a directory's contents without per-file API calls.
func (r *DockerRuntime) CopyDirFromContainer(ctx context.Context, id, containerPath string) (io.ReadCloser, error) {
	rc, _, err := r.cli.CopyFromContainer(ctx, id, containerPath)
	if err != nil {
		return nil, fmt.Errorf("copy from container: %w", err)
	}
	return rc, nil
}

// acquireImage ensures the requested image is present locally. Fast path:
// already on disk → return. Otherwise:
//
//   - `panel-*` images are NEVER published to a registry. We auto-build
//     from buildContext/Dockerfile when buildContext is set. This is the
//     "drop in a new agent and any module just works" behavior — first
//     instance create on a fresh agent triggers the local build of the
//     module's runtime image. Subsequent creates hit the inspect-success
//     fast path.
//
//   - Non-panel images (alpine, acekorneya/asa-linux-server, etc.) are
//     pulled from the registry as usual.
//
// If the image is `panel-*` and buildContext is empty (no Dockerfile in
// the module dir), we surface the same "build it on the agent host"
// message as before — the operator's module is broken, not the agent.
func (r *DockerRuntime) acquireImage(ctx context.Context, ref, buildContext string, sink func(string)) error {
	emit := func(line string) {
		if sink != nil {
			sink(line)
		}
	}
	if _, err := r.cli.ImageInspect(ctx, ref); err == nil {
		return nil
	} else if !client.IsErrNotFound(err) {
		return fmt.Errorf("image inspect: %w", err)
	}

	isPanel := strings.HasPrefix(ref, "panel-") && !strings.Contains(ref, "/")
	if isPanel {
		if buildContext == "" {
			return fmt.Errorf("module image %q not built on this agent and no build context provided", ref)
		}
		dockerfile := filepath.Join(buildContext, "Dockerfile")
		if _, err := os.Stat(dockerfile); err != nil {
			return fmt.Errorf("module image %q missing on this agent and no Dockerfile at %s", ref, dockerfile)
		}
		emit(fmt.Sprintf("[image] %s missing on this agent — building from %s/Dockerfile (first-run; takes 30s–3min)", ref, buildContext))
		return r.buildLocalImage(ctx, ref, buildContext, emit)
	}

	// Public image — pull from registry.
	emit(fmt.Sprintf("[image] pulling %s from registry…", ref))
	rc, err := r.cli.ImagePull(ctx, ref, image.PullOptions{})
	if err != nil {
		return fmt.Errorf("image pull: %w", err)
	}
	defer rc.Close()
	streamDockerJSON(rc, emit)
	emit(fmt.Sprintf("[image] %s ready", ref))
	return nil
}

// buildLocalImage runs `docker build -t <ref> <ctxDir>` via the Docker
// daemon's API. Build output is parsed (NDJSON) and forwarded to `emit`
// so the controller's Console tab gets live progress while the operator
// waits for first-time builds.
func (r *DockerRuntime) buildLocalImage(ctx context.Context, ref, ctxDir string, emit func(string)) error {
	tarball, err := archive.TarWithOptions(ctxDir, &archive.TarOptions{})
	if err != nil {
		return fmt.Errorf("tar build context: %w", err)
	}
	defer tarball.Close()
	resp, err := r.cli.ImageBuild(ctx, tarball, build.ImageBuildOptions{
		Tags:        []string{ref},
		Dockerfile:  "Dockerfile",
		Remove:      true,
		ForceRemove: true,
		PullParent:  false,
	})
	if err != nil {
		return fmt.Errorf("image build start: %w", err)
	}
	defer resp.Body.Close()
	if buildErr := streamDockerJSON(resp.Body, emit); buildErr != nil {
		return fmt.Errorf("image build: %w", buildErr)
	}
	if _, err := r.cli.ImageInspect(ctx, ref); err != nil {
		return fmt.Errorf("image build %q produced no tag: %w", ref, err)
	}
	emit(fmt.Sprintf("[image] %s built and tagged", ref))
	return nil
}

// streamDockerJSON parses Docker's streaming JSON output (one object per
// line, fields like {"stream":"...","status":"...","error":"...","errorDetail":{...}})
// and forwards meaningful lines to emit. Returns a non-nil error iff the
// daemon reported an error in the stream.
func streamDockerJSON(rc io.Reader, emit func(string)) error {
	dec := json.NewDecoder(rc)
	for {
		var msg struct {
			Stream      string          `json:"stream"`
			Status      string          `json:"status"`
			Progress    string          `json:"progress"`
			ID          string          `json:"id"`
			Error       string          `json:"error"`
			ErrorDetail json.RawMessage `json:"errorDetail"`
		}
		if err := dec.Decode(&msg); err != nil {
			if errors.Is(err, io.EOF) {
				return nil
			}
			// Non-JSON tail / partial trailer — stop parsing but don't
			// fail the build; the post-build ImageInspect will catch
			// any tag-missing case.
			return nil
		}
		if msg.Error != "" {
			return errors.New(msg.Error)
		}
		// Build → "stream" lines (Step 1/12 : FROM debian:slim, etc).
		if s := strings.TrimRight(msg.Stream, "\r\n"); s != "" {
			emit(s)
		}
		// Pull → "status" lines, sometimes paired with progress for
		// each layer. Skip the high-frequency "Downloading" updates
		// (would spam the console at 10–30 Hz) — keep "Pulling /
		// Pulled" markers and final messages.
		if msg.Status != "" {
			st := msg.Status
			if strings.HasPrefix(st, "Downloading") || strings.HasPrefix(st, "Extracting") || strings.HasPrefix(st, "Verifying") {
				continue
			}
			if msg.ID != "" {
				emit(fmt.Sprintf("%s: %s", msg.ID, st))
			} else {
				emit(st)
			}
		}
	}
}

// buildPortBindings translates PortSpec to Docker's nat.PortSet / PortMap.
func buildPortBindings(ports []PortSpec) (nat.PortSet, nat.PortMap, error) {
	exposed := nat.PortSet{}
	bindings := nat.PortMap{}
	for _, p := range ports {
		proto := strings.ToLower(p.Proto)
		if proto != "tcp" && proto != "udp" {
			return nil, nil, fmt.Errorf("port %d/%s: proto must be tcp or udp", p.ContainerPort, p.Proto)
		}
		natPort, err := nat.NewPort(proto, strconv.Itoa(int(p.ContainerPort)))
		if err != nil {
			return nil, nil, fmt.Errorf("invalid port %d/%s: %w", p.ContainerPort, proto, err)
		}
		exposed[natPort] = struct{}{}
		hostPort := p.HostPort
		if hostPort == 0 {
			hostPort = p.ContainerPort
		}
		bindings[natPort] = []nat.PortBinding{{
			HostIP:   p.HostIP,
			HostPort: strconv.Itoa(int(hostPort)),
		}}
	}
	return exposed, bindings, nil
}

func pidsPtr(v int64) *int64 {
	if v <= 0 {
		return nil
	}
	return &v
}

// splitDockerTimestamp pulls docker's RFC3339Nano timestamp prefix off a
// log line and returns the remainder + parsed time. Docker emits each
// line as "<rfc3339nano-z> <line>". A space immediately follows the Z;
// anything else (including missing/garbled prefix) falls back to
// time.Now() so we never drop the line.
func splitDockerTimestamp(s string) (string, time.Time) {
	// Minimum valid prefix: "2026-01-01T00:00:00Z " — 21 chars. With nano
	// precision it's longer; the space after Z is what we look for.
	sp := strings.IndexByte(s, ' ')
	if sp < 20 || sp > 35 {
		return s, time.Now()
	}
	t, err := time.Parse(time.RFC3339Nano, s[:sp])
	if err != nil {
		return s, time.Now()
	}
	return s[sp+1:], t
}

// scanLogLine is a bufio.SplitFunc that treats \n, \r, and \r\n as line
// terminators. The stdlib default (bufio.ScanLines) only splits on \n and
// will buffer everything between \r-overwrites until it sees a real newline
// — that's how SteamCMD's "Update state downloading progress: X%" bars
// used to sit silent for minutes before dumping in one chunk.
func scanLogLine(data []byte, atEOF bool) (advance int, token []byte, err error) {
	if atEOF && len(data) == 0 {
		return 0, nil, nil
	}
	if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
		// Swallow a trailing \n after \r so "\r\n" yields one line not two.
		advance = i + 1
		if data[i] == '\r' && i+1 < len(data) && data[i+1] == '\n' {
			advance++
		}
		return advance, bytes.TrimRight(data[:i], "\r\n"), nil
	}
	if atEOF {
		return len(data), data, nil
	}
	return 0, nil, nil
}

// ansiRE matches ANSI CSI sequences (colour, cursor movement, erase-line, …).
// vinanrra's LGSM wrapper for 7DTD emits these liberally; they're noise in a
// browser console and garble line-collapsing logic on the UI side.
var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)

func stripANSI(s string) string {
	if !strings.ContainsRune(s, 0x1b) {
		return s
	}
	return ansiRE.ReplaceAllString(s, "")
}
