// Package stack provides docker-compose ("stack") management for Tinyforge.
//
// Stacks are a first-class concept distinct from single-container Projects:
// users upload a docker-compose YAML, which is stored as an append-only
// revision and deployed via the `docker compose` CLI. Rollback is a new
// revision whose YAML is copied from an older one, redeployed.
package stack

import (
	"bytes"
	"context"
	"fmt"
	"os/exec"
	"strings"
)

// Compose is a thin wrapper around the `docker compose` CLI.
// It intentionally shells out rather than using the Docker SDK: compose has
// no first-class Go SDK, and the CLI is the canonical interface.
type Compose struct {
	binary string // path to `docker` binary; defaults to "docker"
}

// NewCompose returns a Compose wrapper. If binary is empty, "docker" is used.
func NewCompose(binary string) *Compose {
	if binary == "" {
		binary = "docker"
	}
	return &Compose{binary: binary}
}

// Available returns nil if `docker compose version` succeeds.
func (c *Compose) Available(ctx context.Context) error {
	cmd := exec.CommandContext(ctx, c.binary, "compose", "version")
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("docker compose not available: %w (output: %s)", err, string(out))
	}
	return nil
}

// Up runs `docker compose -p <projectName> -f <yamlPath> up -d`.
// Returns combined stdout+stderr for log persistence.
func (c *Compose) Up(ctx context.Context, projectName, yamlPath string) (string, error) {
	return c.run(ctx, projectName, "-f", yamlPath, "up", "-d", "--remove-orphans")
}

// Down runs `docker compose -p <projectName> down`.
// removeVolumes controls whether named volumes are also removed (`-v`).
func (c *Compose) Down(ctx context.Context, projectName string, removeVolumes bool) (string, error) {
	args := []string{"down", "--remove-orphans"}
	if removeVolumes {
		args = append(args, "-v")
	}
	return c.run(ctx, projectName, args...)
}

// Stop runs `docker compose -p <projectName> stop`.
func (c *Compose) Stop(ctx context.Context, projectName string) (string, error) {
	return c.run(ctx, projectName, "stop")
}

// Start runs `docker compose -p <projectName> start`.
func (c *Compose) Start(ctx context.Context, projectName string) (string, error) {
	return c.run(ctx, projectName, "start")
}

// Service is a single row of `docker compose ps --format json`.
type Service struct {
	Name     string `json:"Name"`
	Service  string `json:"Service"`
	State    string `json:"State"`
	Status   string `json:"Status"`
	Health   string `json:"Health"`
	ExitCode int    `json:"ExitCode"`
}

// Ps runs `docker compose -p <projectName> -f <yamlPath> ps --format json`
// and returns one Service per running+stopped service. yamlPath may be empty
// (compose uses stored state when known).
func (c *Compose) Ps(ctx context.Context, projectName, yamlPath string) ([]Service, error) {
	args := []string{}
	if yamlPath != "" {
		args = append(args, "-f", yamlPath)
	}
	args = append(args, "ps", "--format", "json", "--all")
	out, err := c.run(ctx, projectName, args...)
	if err != nil {
		return nil, err
	}
	return parsePsOutput(out), nil
}

// Logs runs `docker compose -p <projectName> logs --no-color --tail=<n> <service>`.
// If service is empty, logs for all services are returned. The service arg
// is preceded by `--` so a service name that begins with `-` cannot be
// re-parsed as a flag by the docker CLI (flag-injection guard).
func (c *Compose) Logs(ctx context.Context, projectName, service string, tail int) (string, error) {
	args := []string{"logs", "--no-color", fmt.Sprintf("--tail=%d", tail)}
	if service != "" {
		args = append(args, "--", service)
	}
	return c.run(ctx, projectName, args...)
}

// run executes `docker compose -p <projectName> <args...>` and returns
// combined output. projectName is verified not to begin with `-` because
// `docker compose -p '--foo'` would otherwise be re-parsed as a flag —
// the callers already sanitize project names through projectNameSanitizer,
// but a belt-and-braces refusal here means any future caller cannot
// accidentally bypass the sanitizer.
func (c *Compose) run(ctx context.Context, projectName string, args ...string) (string, error) {
	if projectName == "" || strings.HasPrefix(projectName, "-") {
		return "", fmt.Errorf("docker compose: refusing project name %q", projectName)
	}
	full := append([]string{"compose", "-p", projectName}, args...)
	cmd := exec.CommandContext(ctx, c.binary, full...)
	var buf bytes.Buffer
	cmd.Stdout = &buf
	cmd.Stderr = &buf
	err := cmd.Run()
	out := buf.String()
	if err != nil {
		return out, fmt.Errorf("docker compose %s: %w (output: %s)",
			strings.Join(args, " "), err, strings.TrimSpace(out))
	}
	return out, nil
}
