package docker

import (
	"context"
	"crypto/sha256"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"

	"github.com/docker/cli/cli/command"
	"github.com/docker/cli/cli/flags"
	"github.com/docker/compose/v5/pkg/api"
	"github.com/docker/compose/v5/pkg/compose"
	"github.com/docker/docker/api/types/build"
	dcontainer "github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/filters"
	dockerclient "github.com/docker/docker/client"
	"github.com/docker/docker/pkg/jsonmessage"
	"github.com/fatih/color"
	archive "github.com/moby/go-archive"
	"github.com/moby/term"

	"github.com/seznam/jailoc/internal/config"
	"github.com/seznam/jailoc/internal/embed"
	"github.com/seznam/jailoc/internal/workspace"
)

func displayStream(r io.Reader) error {
	fd, isTerminal := term.GetFdInfo(os.Stderr)
	return jsonmessage.DisplayJSONMessagesStream(r, os.Stderr, fd, isTerminal, nil)
}

// progressEventProcessor filters compose SDK events to show only important state changes.
// It prints completion (Done) and error events, skipping intermediate progress ticks.
type progressEventProcessor struct{}

var (
	progressDoneColor  = color.New(color.FgGreen)
	progressErrorColor = color.New(color.FgRed)
)

func (p *progressEventProcessor) Start(_ context.Context, _ string) {}
func (p *progressEventProcessor) Done(_ string, _ bool)             {}

func (p *progressEventProcessor) On(events ...api.Resource) {
	for _, e := range events {
		switch e.Status {
		case api.Done:
			_, _ = progressDoneColor.Printf("  %s %s\n", e.ID, e.Text)
		case api.Error:
			_, _ = progressErrorColor.Printf("  %s %s: %s\n", e.ID, e.Text, e.Details)
		}
	}
}

type Client struct {
	composeFile string
	workDir     string
	workspace   string
	svcOnce     sync.Once
	svcErr      error
	svc         api.Compose
}

func NewClient(composeFile, workDir, workspace string) *Client {
	return &Client{
		composeFile: composeFile,
		workDir:     workDir,
		workspace:   workspace,
	}
}

func (c *Client) Up(ctx context.Context) error {
	slog.Info("compose up", "workspace", c.workspace)

	if err := c.initComposeSvc(); err != nil {
		return err
	}

	_, _ = color.New(color.FgCyan).Printf("Loading compose project...\n")
	project, err := c.svc.LoadProject(ctx, api.ProjectLoadOptions{
		ConfigPaths: []string{c.composeFile},
		ProjectName: "jailoc-" + c.workspace,
	})
	if err != nil {
		return fmt.Errorf("load compose project for workspace %q: %w", c.workspace, err)
	}

	// RecreateDiverged (default): containers are recreated only when the
	// compose configuration changes. RemoveOrphans removes services that are
	// no longer defined (e.g. dind after disabling enable_docker).
	if err := c.svc.Up(ctx, project, api.UpOptions{
		Create: api.CreateOptions{RemoveOrphans: true},
	}); err != nil {
		return fmt.Errorf("compose up for workspace %q: %w", c.workspace, err)
	}

	return nil
}

func (c *Client) Down(ctx context.Context) error {
	slog.Info("compose down", "workspace", c.workspace)

	if err := c.initComposeSvc(); err != nil {
		return err
	}

	if err := c.svc.Down(ctx, "jailoc-"+c.workspace, api.DownOptions{}); err != nil {
		return fmt.Errorf("compose down for workspace %q: %w", c.workspace, err)
	}

	return nil
}

func (c *Client) IsRunning(ctx context.Context) (bool, error) {
	slog.Debug("checking container running status", "workspace", c.workspace)

	container, err := c.opencodeContainer(ctx)
	if err != nil {
		return false, err
	}

	return container.ID != "", nil
}

func (c *Client) CurrentContainerID(ctx context.Context) (string, error) {
	container, err := c.opencodeContainer(ctx)
	if err != nil {
		return "", err
	}

	return container.ID, nil
}

func (c *Client) HealthStatus(ctx context.Context) (string, error) {
	container, err := c.opencodeContainer(ctx)
	if err != nil {
		return "", err
	}

	return string(container.Health), nil
}

// ContainerState returns the state and exit code of the opencode container,
// regardless of whether it is running. Returns empty state if no container exists.
func (c *Client) ContainerState(ctx context.Context) (state string, exitCode int, err error) {
	if err := c.initComposeSvc(); err != nil {
		return "", 0, err
	}

	containers, err := c.svc.Ps(ctx, "jailoc-"+c.workspace, api.PsOptions{All: true})
	if err != nil {
		return "", 0, fmt.Errorf("compose ps for workspace %q: %w", c.workspace, err)
	}

	ct := anyOpencodeContainer(containers)
	return string(ct.State), ct.ExitCode, nil
}

func (c *Client) opencodeContainer(ctx context.Context) (api.ContainerSummary, error) {
	if err := c.initComposeSvc(); err != nil {
		return api.ContainerSummary{}, err
	}

	containers, err := c.svc.Ps(ctx, "jailoc-"+c.workspace, api.PsOptions{All: true})
	if err != nil {
		return api.ContainerSummary{}, fmt.Errorf("compose ps for workspace %q: %w", c.workspace, err)
	}

	return currentOpencodeContainer(containers), nil
}

func currentOpencodeContainer(containers []api.ContainerSummary) api.ContainerSummary {
	var selected api.ContainerSummary

	for _, ct := range containers {
		if ct.Service != "opencode" || ct.State != "running" {
			continue
		}

		if selected.ID == "" || ct.Created > selected.Created {
			selected = ct
		}
	}

	return selected
}

// anyOpencodeContainer returns the opencode container regardless of state.
// It prefers running containers; if none are running, it returns the most recently created one.
func anyOpencodeContainer(containers []api.ContainerSummary) api.ContainerSummary {
	var selected api.ContainerSummary

	for _, ct := range containers {
		if ct.Service != "opencode" {
			continue
		}

		if selected.ID == "" {
			selected = ct
			continue
		}

		// Prefer running over non-running.
		if ct.State == "running" && selected.State != "running" {
			selected = ct
			continue
		}
		if ct.State != "running" && selected.State == "running" {
			continue
		}

		// Same state class — prefer newest.
		if ct.Created > selected.Created {
			selected = ct
		}
	}

	return selected
}

type writerLogConsumer struct{ w io.Writer }

func (wlc *writerLogConsumer) Log(name, msg string) {
	_, _ = fmt.Fprintf(wlc.w, "%s\n", msg)
}

func (wlc *writerLogConsumer) Err(name, msg string) {
	_, _ = fmt.Fprintf(wlc.w, "%s\n", msg)
}

func (wlc *writerLogConsumer) Status(name, msg string) {}

func (c *Client) Logs(ctx context.Context, follow bool, w io.Writer) error {
	if err := c.initComposeSvc(); err != nil {
		return err
	}

	consumer := &writerLogConsumer{w: w}
	if err := c.svc.Logs(ctx, "jailoc-"+c.workspace, consumer, api.LogOptions{Follow: follow}); err != nil {
		return fmt.Errorf("compose logs for workspace %q: %w", c.workspace, err)
	}

	return nil
}

func (c *Client) TailLogs(ctx context.Context, n int, w io.Writer) error {
	if err := c.initComposeSvc(); err != nil {
		return err
	}

	consumer := &writerLogConsumer{w: w}
	if err := c.svc.Logs(ctx, "jailoc-"+c.workspace, consumer, api.LogOptions{Tail: strconv.Itoa(n)}); err != nil {
		return fmt.Errorf("tail compose logs for workspace %q: %w", c.workspace, err)
	}

	return nil
}

func (c *Client) Exec(ctx context.Context, args []string, env []string, stdin io.Reader, stdout, stderr io.Writer) error {
	var stdinRC io.ReadCloser
	if stdin != nil {
		if rc, ok := stdin.(io.ReadCloser); ok {
			stdinRC = rc
		} else {
			stdinRC = io.NopCloser(stdin)
		}
	}

	cliOpts := []command.CLIOption{
		command.WithOutputStream(stdout),
		command.WithErrorStream(stderr),
	}
	if stdinRC != nil {
		cliOpts = append(cliOpts, command.WithInputStream(stdinRC))
	}

	dockerCLI, err := command.NewDockerCli(cliOpts...)
	if err != nil {
		return fmt.Errorf("create Docker CLI for exec: %w", err)
	}

	if err := dockerCLI.Initialize(&flags.ClientOptions{}); err != nil {
		return fmt.Errorf("initialize Docker CLI for exec: %w", err)
	}

	svc, err := compose.NewComposeService(dockerCLI)
	if err != nil {
		return fmt.Errorf("create Compose service for exec: %w", err)
	}

	exitCode, err := svc.Exec(ctx, "jailoc-"+c.workspace, api.RunOptions{
		Service:     "opencode",
		Command:     args,
		Tty:         true,
		Interactive: stdin != nil,
		Index:       1,
		Environment: env,
	})
	if err != nil {
		return fmt.Errorf("compose exec for workspace %q: %w", c.workspace, err)
	}

	if exitCode != 0 {
		return fmt.Errorf("exec exited with code %d", exitCode)
	}

	return nil
}

// TerminalSize represents a terminal width and height in characters.
type TerminalSize struct {
	Width  uint
	Height uint
}

// ExecInteractive runs a command in the opencode container using the Docker
// Engine API directly (not Compose SDK). This gives the caller full control
// over TTY resize via the resizeCh channel. Each TerminalSize sent on resizeCh
// triggers a ContainerExecResize call. The method blocks until the exec
// process exits or the context is cancelled.
func (c *Client) ExecInteractive(ctx context.Context, containerID string, args []string, env []string, stdin io.Reader, stdout io.Writer, resizeCh <-chan TerminalSize) error {
	engineCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
	if err != nil {
		return fmt.Errorf("create Docker Engine client for exec: %w", err)
	}

	// done is closed before engineCli to ensure the resize goroutine exits
	// before the client is closed, preventing calls on a closed client.
	done := make(chan struct{})
	defer func() {
		close(done)
		_ = engineCli.Close()
	}()

	execCfg := dcontainer.ExecOptions{
		Cmd:          args,
		Env:          env,
		AttachStdin:  stdin != nil,
		AttachStdout: true,
		AttachStderr: true,
		Tty:          true,
	}

	// Apply initial terminal size before starting the exec so the process
	// launches with the correct dimensions (avoids startup-size race).
	if resizeCh != nil {
		select {
		case size := <-resizeCh:
			execCfg.ConsoleSize = &[2]uint{size.Height, size.Width}
		default:
		}
	}

	execResp, err := engineCli.ContainerExecCreate(ctx, containerID, execCfg)
	if err != nil {
		return fmt.Errorf("create exec in container %s: %w", containerID, err)
	}
	execID := execResp.ID

	attachResp, err := engineCli.ContainerExecAttach(ctx, execID, dcontainer.ExecAttachOptions{Tty: true})
	if err != nil {
		return fmt.Errorf("attach to exec %s: %w", execID, err)
	}
	defer attachResp.Close()

	if resizeCh != nil {
		go func() {
			for {
				select {
				case size, ok := <-resizeCh:
					if !ok {
						return
					}
					_ = engineCli.ContainerExecResize(ctx, execID, dcontainer.ResizeOptions{
						Width:  size.Width,
						Height: size.Height,
					})
				case <-done:
					return
				case <-ctx.Done():
					return
				}
			}
		}()
	}

	if stdin != nil {
		go func() {
			_, _ = io.Copy(attachResp.Conn, stdin)
			_ = attachResp.CloseWrite()
		}()
	}

	_, copyErr := io.Copy(stdout, attachResp.Reader)

	// Exec finished — close connection to unblock the stdin goroutine if
	// it's in Conn.Write(). If blocked in os.Stdin.Read() (uninterruptible),
	// the goroutine exits harmlessly at process exit.
	attachResp.Close()

	inspect, err := engineCli.ContainerExecInspect(ctx, execID)
	if err != nil {
		if copyErr != nil && !errors.Is(copyErr, io.EOF) {
			return fmt.Errorf("copy exec output: %w", copyErr)
		}
		return fmt.Errorf("inspect exec %s: %w", execID, err)
	}

	if inspect.ExitCode != 0 {
		return fmt.Errorf("exec exited with code %d", inspect.ExitCode)
	}

	if copyErr != nil && !errors.Is(copyErr, io.EOF) {
		return fmt.Errorf("copy exec output: %w", copyErr)
	}

	return nil
}

func (c *Client) initComposeSvc() error {
	c.svcOnce.Do(func() {
		dockerCLI, err := command.NewDockerCli()
		if err != nil {
			c.svcErr = fmt.Errorf("create Docker CLI: %w", err)
			return
		}
		if err := dockerCLI.Initialize(&flags.ClientOptions{}); err != nil {
			c.svcErr = fmt.Errorf("initialize Docker CLI: %w", err)
			return
		}
		svc, err := compose.NewComposeService(dockerCLI, compose.WithEventProcessor(&progressEventProcessor{}))
		if err != nil {
			c.svcErr = fmt.Errorf("create Compose service: %w", err)
			return
		}
		c.svc = svc
	})

	return c.svcErr
}

func ResolveBaseImage(ctx context.Context, cfg *config.Config, version string) (string, error) {
	slog.Debug("resolving base image", "version", version)

	if cfg != nil && strings.TrimSpace(cfg.Base.Dockerfile) != "" {
		source := strings.TrimSpace(cfg.Base.Dockerfile)
		_, _ = color.New(color.FgCyan).Printf("Loading preset Dockerfile from %s...\n", source)
		content, err := loadDockerfile(ctx, source)
		if err != nil {
			return "", fmt.Errorf("load dockerfile from %q: %w", source, err)
		}

		engineCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
		if err != nil {
			return "", fmt.Errorf("create Docker Engine client for preset build: %w", err)
		}
		defer func() { _ = engineCli.Close() }()

		_, _ = color.New(color.FgCyan).Printf("Building preset base image...\n")
		tag, err := buildPresetImage(ctx, engineCli, content)
		if err != nil {
			return "", fmt.Errorf("build preset image: %w", err)
		}

		slog.Debug("base image resolved", "tag", tag)
		return tag, nil
	}

	const embeddedTag = "jailoc-base:embedded"
	engineCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
	if err != nil {
		return "", fmt.Errorf("create Docker Engine client for embedded build: %w", err)
	}
	defer func() { _ = engineCli.Close() }()

	_, _ = color.New(color.FgCyan).Printf("Building embedded base image...\n")
	if err := buildEmbeddedImage(ctx, engineCli, embeddedTag); err != nil {
		return "", fmt.Errorf("build embedded base image: %w", err)
	}

	slog.Debug("base image resolved", "tag", embeddedTag)
	return embeddedTag, nil
}

// proxyBuildArgs returns Docker build args for proxy environment variables
// set in the current process environment. Docker Engine API (unlike Docker CLI)
// does not forward these automatically — they must be passed explicitly.
func proxyBuildArgs() map[string]*string {
	keys := []string{
		"HTTP_PROXY", "http_proxy",
		"HTTPS_PROXY", "https_proxy",
		"NO_PROXY", "no_proxy",
		"FTP_PROXY", "ftp_proxy",
		"ALL_PROXY", "all_proxy",
	}

	args := make(map[string]*string)
	for _, k := range keys {
		if v, ok := os.LookupEnv(k); ok {
			args[k] = &v
		}
	}

	return args
}

func buildEmbeddedImage(ctx context.Context, cli dockerclient.APIClient, tag string) error {
	tmpDir, err := os.MkdirTemp("", "jailoc-embedded-dockerfile-")
	if err != nil {
		return fmt.Errorf("create temp directory for embedded Dockerfile: %w", err)
	}
	defer func() { _ = os.RemoveAll(tmpDir) }()

	buildContextDir := tmpDir
	slog.Debug("building image", "tag", tag, "context", buildContextDir)

	dockerfilePath := filepath.Join(tmpDir, "Dockerfile")
	if err := os.WriteFile(dockerfilePath, embed.Dockerfile(), 0o600); err != nil {
		return fmt.Errorf("write embedded Dockerfile to %q: %w", dockerfilePath, err)
	}

	buildCtx, err := archive.TarWithOptions(tmpDir, &archive.TarOptions{})
	if err != nil {
		return fmt.Errorf("create build context tar for %q: %w", tmpDir, err)
	}
	defer func() { _ = buildCtx.Close() }()

	resp, err := cli.ImageBuild(ctx, buildCtx, build.ImageBuildOptions{
		Tags:      []string{tag},
		BuildArgs: proxyBuildArgs(),
		Remove:    true,
	})
	if err != nil {
		return fmt.Errorf("build embedded image in %q: %w", tmpDir, err)
	}
	defer func() { _ = resp.Body.Close() }()

	if err := displayStream(resp.Body); err != nil {
		return fmt.Errorf("read embedded build output: %w", err)
	}

	return nil
}

func buildPresetImage(ctx context.Context, cli dockerclient.APIClient, dockerfileContent []byte) (string, error) {
	if len(dockerfileContent) == 0 {
		return "", fmt.Errorf("dockerfile content is empty")
	}

	tmpDir, err := os.MkdirTemp("", "jailoc-preset-dockerfile-")
	if err != nil {
		return "", fmt.Errorf("create temp directory for preset Dockerfile: %w", err)
	}
	defer func() { _ = os.RemoveAll(tmpDir) }()

	dockerfilePath := filepath.Join(tmpDir, "Dockerfile")
	if err := os.WriteFile(dockerfilePath, dockerfileContent, 0o600); err != nil {
		return "", fmt.Errorf("write preset Dockerfile to %q: %w", dockerfilePath, err)
	}

	hash := sha256.Sum256(dockerfileContent)
	presetTag := fmt.Sprintf("jailoc-base:preset-%x", hash[:8])
	slog.Debug("building image", "tag", presetTag, "context", tmpDir)

	buildCtx, err := archive.TarWithOptions(tmpDir, &archive.TarOptions{})
	if err != nil {
		return "", fmt.Errorf("create build context tar for %q: %w", tmpDir, err)
	}
	defer func() { _ = buildCtx.Close() }()

	resp, err := cli.ImageBuild(ctx, buildCtx, build.ImageBuildOptions{
		Tags:      []string{presetTag},
		BuildArgs: proxyBuildArgs(),
		Remove:    true,
	})
	if err != nil {
		return "", fmt.Errorf("build preset image in %q: %w", tmpDir, err)
	}
	defer func() { _ = resp.Body.Close() }()

	if err := displayStream(resp.Body); err != nil {
		return "", fmt.Errorf("read preset build output: %w", err)
	}

	return presetTag, nil
}

func BuildOverlayImage(ctx context.Context, base string, ws workspace.Resolved) (string, error) {
	if strings.TrimSpace(base) == "" {
		return "", fmt.Errorf("base image is empty")
	}

	if strings.TrimSpace(ws.Dockerfile) == "" {
		return base, nil
	}

	_, _ = color.New(color.FgCyan).Printf("Loading workspace Dockerfile from %s...\n", ws.Dockerfile)
	dockerfileContent, err := loadDockerfile(ctx, ws.Dockerfile)
	if err != nil {
		return "", fmt.Errorf("load workspace dockerfile from %q: %w", ws.Dockerfile, err)
	}

	dockerfileContent = injectBaseArg(dockerfileContent)

	buildContextDir, cleanupCtx, err := resolveOverlayBuildContext(ws)
	if err != nil {
		return "", fmt.Errorf("determine build context for workspace %q: %w", ws.Name, err)
	}
	defer cleanupCtx()

	tmpDockerfile, err := os.CreateTemp(buildContextDir, "jailoc-overlay-*.Dockerfile")
	if err != nil {
		return "", fmt.Errorf("write temporary workspace Dockerfile in %q: %w", buildContextDir, err)
	}
	tmpDockerfilePath := tmpDockerfile.Name()
	defer func() { _ = os.Remove(tmpDockerfilePath) }()

	if _, err := tmpDockerfile.Write(dockerfileContent); err != nil {
		_ = tmpDockerfile.Close()
		return "", fmt.Errorf("write temporary workspace Dockerfile %q: %w", tmpDockerfilePath, err)
	}
	if err := tmpDockerfile.Close(); err != nil {
		return "", fmt.Errorf("close temporary workspace Dockerfile %q: %w", tmpDockerfilePath, err)
	}

	// Workspace overlays are rebuilt unconditionally on every workspace startup.
	// We do NOT check whether overlayTag already exists locally and skip the build —
	// that would silently run a stale image when the user edits their Dockerfile
	// but the edit produces a build error. Build failure must abort startup.
	// Daemon-side layer cache already provides the fast path for unchanged Dockerfiles;
	// adding a second cache layer here would only mask user errors.
	hash := sha256.Sum256(dockerfileContent)
	hashHex := fmt.Sprintf("%x", hash)
	overlayTag := fmt.Sprintf("jailoc-%s:%s", ws.Name, hashHex[:8])

	engineCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
	if err != nil {
		return "", fmt.Errorf("create Docker Engine client for workspace %q overlay build: %w", ws.Name, err)
	}
	defer func() { _ = engineCli.Close() }()

	buildCtx, err := archive.TarWithOptions(buildContextDir, &archive.TarOptions{})
	if err != nil {
		return "", fmt.Errorf("create build context tar for workspace %q from %q: %w", ws.Name, buildContextDir, err)
	}
	defer func() { _ = buildCtx.Close() }()

	baseArg := base
	overlayBuildArgs := proxyBuildArgs()
	overlayBuildArgs["BASE"] = &baseArg

	_, _ = color.New(color.FgCyan).Printf("Building workspace overlay image (sending build context to Docker daemon)...\n")
	resp, err := engineCli.ImageBuild(ctx, buildCtx, build.ImageBuildOptions{
		Tags:       []string{overlayTag},
		BuildArgs:  overlayBuildArgs,
		Dockerfile: filepath.Base(tmpDockerfilePath),
		Remove:     true,
	})
	if err != nil {
		return "", fmt.Errorf("build workspace overlay image %q from %q: %w", overlayTag, buildContextDir, err)
	}
	defer func() { _ = resp.Body.Close() }()

	if err := displayStream(resp.Body); err != nil {
		return "", fmt.Errorf("read workspace overlay build output for %q: %w", ws.Name, err)
	}

	return overlayTag, nil
}

// injectBaseArg inserts "ARG BASE\n" after any leading parser directives (# syntax=...,
// # escape=..., # check=...) so that ${BASE} is substitutable in FROM instructions without
// breaking directives that must appear before any instruction.
func injectBaseArg(content []byte) []byte {
	const argLine = "ARG BASE\n"
	i := 0
	for i < len(content) {
		lineEnd := i
		for lineEnd < len(content) && content[lineEnd] != '\n' {
			lineEnd++
		}
		if lineEnd < len(content) {
			lineEnd++
		}
		line := strings.TrimSpace(string(content[i:lineEnd]))
		if line == "" || (strings.HasPrefix(line, "#") && strings.Contains(line, "=")) {
			i = lineEnd
			continue
		}
		break
	}
	result := make([]byte, 0, len(content)+len(argLine))
	result = append(result, content[:i]...)
	result = append(result, argLine...)
	result = append(result, content[i:]...)
	return result
}

func resolveOverlayBuildContext(ws workspace.Resolved) (dir string, cleanup func(), err error) {
	if strings.TrimSpace(ws.BuildContext) != "" {
		return ws.BuildContext, func() {}, nil
	}

	sourceKind, err := detectSourceType(ws.Dockerfile)
	if err != nil {
		return "", func() {}, fmt.Errorf("detect dockerfile source type: %w", err)
	}

	if sourceKind == sourceLocal {
		return filepath.Dir(ws.Dockerfile), func() {}, nil
	}

	tmpDir, err := os.MkdirTemp("", "jailoc-overlay-context-")
	if err != nil {
		return "", func() {}, fmt.Errorf("create temporary build context for HTTP dockerfile: %w", err)
	}

	return tmpDir, func() { _ = os.RemoveAll(tmpDir) }, nil
}

// RunningWorkspacePorts returns a map of workspace name → published host port
// for all running jailoc opencode containers.
func RunningWorkspacePorts(ctx context.Context) (map[string]int, error) {
	cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
	if err != nil {
		return nil, fmt.Errorf("create docker client: %w", err)
	}
	defer func() { _ = cli.Close() }()

	containers, err := cli.ContainerList(ctx, dcontainer.ListOptions{
		Filters: filters.NewArgs(
			filters.Arg("label", "com.docker.compose.service=opencode"),
			filters.Arg("status", "running"),
		),
	})
	if err != nil {
		return nil, fmt.Errorf("list containers: %w", err)
	}

	return workspacePortsFromContainers(containers), nil
}

// workspacePortsFromContainers extracts workspace name → published host port
// from a list of Docker containers. Only containers belonging to jailoc compose
// projects (project label prefixed with "jailoc-") are included.
func workspacePortsFromContainers(containers []dcontainer.Summary) map[string]int {
	result := make(map[string]int, len(containers))
	for _, ct := range containers {
		name, ok := strings.CutPrefix(ct.Labels["com.docker.compose.project"], "jailoc-")
		if !ok {
			continue
		}
		for _, p := range ct.Ports {
			if p.PrivatePort == uint16(workspace.BasePort) && p.PublicPort != 0 {
				result[name] = int(p.PublicPort)
				break
			}
		}
	}
	return result
}
