// Package deployer is the per-deployment state machine. One Deployer is
// created per DeployRequest; it runs the stages sequentially and emits
// events on its output channel. The caller (the agent) reads events and
// fans them out to the control plane.
//
// Two build flavours share the same lifecycle:
//
//   GoDeployer (microservices) — host `go build`, run alpine:3.20 with the
//     repo bind-mounted at /src and the binary as the container command.
//
//   PhpDeployer (api-gateway)  — host `git pull` only; the gateway's PHP
//     image (php:8.3-apache) is pre-loaded on the VM and the repo is
//     bind-mounted at /app. Best-effort `composer install --no-dev` runs
//     on the host if a composer.json is present.
//
// In both cases there is NO Dockerfile-based image build. The runtime image
// must be loaded on the host ahead of time (manual `docker load`).
package deployer

import (
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	containertypes "github.com/moby/moby/api/types/container"
	networktypes "github.com/moby/moby/api/types/network"
	dockerclient "github.com/moby/moby/client"

	"github.com/sdp/agentlib/gitutil"
	"github.com/sdp/protocol"
)

// Kind selects between build flavours. The control plane doesn't need to
// know — it just sends a DeployRequest with a Repository name. The agent
// picks the right Deployer based on which binary it's running as.
type Kind string

const (
	KindGo Kind = "go"   // microservice: host go build
	KindPHP Kind = "php" // api-gateway: host composer install (best-effort)
)

// Spec captures the kind-specific bits of a Deployer that differ between
// Go and PHP. The constructor functions below fill these in.
type Spec struct {
	ImageTag     string
	BindMount    string // host dir bind-mounted into the container
	Workdir      string // container working dir
	Cmd          []string
	ExposedPorts networktypes.PortSet
}

type Deployer struct {
	kind        Kind
	ID          string
	Repository  string
	RepoPath    string
	Branch      string
	Env         map[string]string
	Creds       gitutil.Creds
	cli         *dockerclient.Client
	containerID string
	spec        Spec
}

func newDeployer(kind Kind, cli *dockerclient.Client, id, repo, repoPath, branch string, env map[string]string, c gitutil.Creds, spec Spec) *Deployer {
	return &Deployer{
		kind:       kind,
		ID:         id,
		Repository: repo,
		RepoPath:   repoPath,
		Branch:     branch,
		Env:        env,
		Creds:      c,
		cli:        cli,
		spec:       spec,
	}
}

// portKey builds a network.Port from a "port/proto" string like "8080/tcp".
func portKey(s string) (networktypes.Port, error) {
	return networktypes.ParsePort(s)
}

// portSet is a small helper to make a PortSet from "port/proto" literals.
func portSet(ports ...string) networktypes.PortSet {
	out := networktypes.PortSet{}
	for _, s := range ports {
		p, err := portKey(s)
		if err == nil {
			out[p] = struct{}{}
		}
	}
	return out
}

// NewGo is the microservice deployer: host `go build`, run alpine:3.20.
func NewGo(cli *dockerclient.Client, id, repo, repoPath, branch string, env map[string]string, c gitutil.Creds) *Deployer {
	binName := "app-" + repo
	return newDeployer(KindGo, cli, id, repo, repoPath, branch, env, c, Spec{
		ImageTag:     "alpine:3.20",
		BindMount:    "/src",
		Workdir:      "/src",
		Cmd:          []string{"/src/" + binName},
		ExposedPorts: portSet("8080/tcp"),
	})
}

// NewPHP is the api-gateway deployer: best-effort composer install on
// the host, then run php:8.3-apache with the repo bind-mounted at /app.
//
// The PHP image must be pre-loaded on the VM via `docker load`. The repo
// is expected to be a standard PHP project layout (public/ entrypoint
// is conventional but not required).
func NewPHP(cli *dockerclient.Client, id, repo, repoPath, branch string, env map[string]string, c gitutil.Creds) *Deployer {
	return newDeployer(KindPHP, cli, id, repo, repoPath, branch, env, c, Spec{
		ImageTag:  "php:8.3-apache",
		BindMount: "/app",
		Workdir:   "/app",
		// Apache in the official php image reads /app/public as the
		// document root by default. If the gateway has a different
		// layout the env var APACHE_DOCUMENT_ROOT can override.
		Cmd:          []string{"apache2-foreground"},
		ExposedPorts: portSet("80/tcp"),
	})
}

// Run executes the kind-specific pipeline and returns the final state
// ("RUNNING" on success, "FAILED" otherwise). All events are pushed to
// out; the caller is expected to drain.
func (d *Deployer) Run(ctx context.Context, out chan<- protocol.Event) string {
	emit := func(kind, state, stage, line string) {
		out <- protocol.Event{
			DeploymentID: d.ID,
			Kind:         kind,
			State:        state,
			Stage:        stage,
			Line:         line,
			At:           time.Now().UnixMilli(),
		}
	}

	stages := []struct {
		name string
		fn   func() error
	}{
		{"git fetch", func() error { _, err := gitutil.Fetch(ctx, d.RepoPath, d.Creds); return err }},
		{"git checkout", func() error { _, err := gitutil.Checkout(ctx, d.RepoPath, d.Branch, d.Creds); return err }},
		{"git pull", func() error { _, err := gitutil.Pull(ctx, d.RepoPath, d.Creds); return err }},
	}
	if d.kind == KindGo {
		stages = append(stages, struct {
			name string
			fn   func() error
		}{"go build", func() error { return d.buildGo(ctx) }})
	} else {
		stages = append(stages, struct {
			name string
			fn   func() error
		}{"composer install", func() error { return d.composerInstall(ctx) }})
	}
	stages = append(stages, struct {
		name string
		fn   func() error
	}{"start container", func() error { return d.startContainer(ctx) }})

	for _, s := range stages {
		emit("progress", "", s.name, "starting "+s.name)
		if err := s.fn(); err != nil {
			emit("progress", "FAILED", s.name, err.Error())
			emit("status", "FAILED", "", "")
			return "FAILED"
		}
		emit("progress", "", s.name, "ok")
	}
	emit("status", "RUNNING", "", "")
	return "RUNNING"
}

func (d *Deployer) buildGo(ctx context.Context) error {
	binName := "app-" + d.Repository
	binPath := filepath.Join(d.RepoPath, binName)
	cmd := exec.CommandContext(ctx, "go", "build", "-o", binPath, "./...")
	cmd.Dir = d.RepoPath
	out, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("go build: %w: %s", err, out)
	}
	return nil
}

// composerInstall runs `composer install --no-dev` on the host if a
// composer.json is present. Best-effort: if composer isn't installed
// or fails, we log a warning and continue — the gateway's PHP image
// typically has its own runtime autoloader or preinstalled deps.
//
// Per REQUIREMENTS.md: the API Gateway must run inside Docker; we
// don't try to launch a PHP dev server or anything fancy here.
func (d *Deployer) composerInstall(_ context.Context) error {
	manifest := filepath.Join(d.RepoPath, "composer.json")
	if _, err := exec.LookPath("composer"); err != nil {
		return nil // no composer on the VM; skip silently
	}
	if _, err := os.Stat(manifest); err != nil {
		return nil // no composer.json; nothing to install
	}
	cmd := exec.Command("composer", "install", "--no-dev", "--no-interaction", "--no-progress")
	cmd.Dir = d.RepoPath
	// Best-effort: even if composer fails, don't fail the deploy.
	_, _ = cmd.CombinedOutput()
	return nil
}

// startContainer runs the configured base image with the repo
// bind-mounted at d.spec.BindMount. The container's working dir is
// set to d.spec.Workdir so the entrypoint finds its config / static
// files.
func (d *Deployer) startContainer(ctx context.Context) error {
	envList := make([]string, 0, len(d.Env))
	for k, v := range d.Env {
		envList = append(envList, k+"="+v)
	}

	cfg := &containertypes.Config{
		Image:        d.spec.ImageTag,
		Cmd:          d.spec.Cmd,
		Env:          envList,
		WorkingDir:   d.spec.Workdir,
		ExposedPorts: d.spec.ExposedPorts,
	}
	hostCfg := &containertypes.HostConfig{
		Binds:         []string{d.RepoPath + ":" + d.spec.BindMount},
		RestartPolicy: containertypes.RestartPolicy{Name: containertypes.RestartPolicyUnlessStopped},
	}

	resp, err := d.cli.ContainerCreate(ctx, dockerclient.ContainerCreateOptions{
		Config:     cfg,
		HostConfig: hostCfg,
		Name:       d.containerName(),
	})
	if err != nil {
		return fmt.Errorf("container create: %w", err)
	}
	d.containerID = resp.ID
	if _, err := d.cli.ContainerStart(ctx, resp.ID, dockerclient.ContainerStartOptions{}); err != nil {
		return fmt.Errorf("container start: %w", err)
	}
	return nil
}

func (d *Deployer) containerName() string {
	return "sdp-" + d.Repository + "-" + d.ID
}

func (d *Deployer) Stop(ctx context.Context) error {
	if d.containerID == "" {
		return nil
	}
	_, err := d.cli.ContainerStop(ctx, d.containerID, dockerclient.ContainerStopOptions{})
	return err
}

// StreamLogs tails the container's logs into the events channel until ctx
// is cancelled.
func (d *Deployer) StreamLogs(ctx context.Context, out chan<- protocol.Event) {
	if d.containerID == "" {
		return
	}
	rc, err := d.cli.ContainerLogs(ctx, d.containerID, dockerclient.ContainerLogsOptions{
		ShowStdout: true,
		ShowStderr: true,
		Follow:     true,
		Timestamps: false,
	})
	if err != nil {
		return
	}
	defer rc.Close()
	// ponytail: Docker multiplexes stdout/stderr; the leading 8 bytes per
	// frame are the stream header. For MVP we just split on newlines and
	// don't try to demux.
	buf := make([]byte, 4096)
	var carry strings.Builder
	for {
		n, err := rc.Read(buf)
		if n > 0 {
			carry.Write(buf[:n])
			for {
				s := carry.String()
				i := strings.IndexByte(s, '\n')
				if i < 0 {
					break
				}
				line := s[:i]
				carry.Reset()
				carry.WriteString(s[i+1:])
				if len(line) > 8 && line[0] <= 2 {
					line = line[8:]
				}
				if line != "" {
					out <- protocol.Event{
						DeploymentID: d.ID,
						Kind:         "log",
						Line:         line,
						At:           time.Now().UnixMilli(),
					}
				}
			}
		}
		if err != nil {
			if err != io.EOF {
				// log nothing; the agent's writer will drop on disconnect
			}
			return
		}
	}
}
