package docker

import (
	"archive/tar"
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/moby/moby/api/types/build"
	"github.com/moby/moby/client"
)

// BuildImage builds a Docker image from a directory containing a Dockerfile
// at the context root. Kept as a thin wrapper around BuildImageAt for the
// static-site plugin which always emits its generated Dockerfile at the
// context root. New code should prefer BuildImageAt so the Dockerfile path
// is explicit.
func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
	return c.BuildImageAt(ctx, contextDir, "Dockerfile", tag, nil)
}

// BuildImageAt builds a Docker image from a tar of contextDir, using the
// Dockerfile at `dockerfile` *inside* the context (typically "Dockerfile"
// but may be e.g. "docker/Dockerfile" when the user-supplied repo layout
// keeps Dockerfiles in a subfolder).
//
// The dockerfile argument is the path *relative to contextDir*. Empty
// strings are normalised to "Dockerfile" so callers can pass through a
// user config value without sanitising twice.
//
// logFn, if non-nil, is invoked for every non-empty `stream` line the
// daemon emits during the build. Callers use this to forward live build
// progress (e.g. SSE bus). Errors from the daemon are NOT delivered via
// logFn — they surface as the returned error so the caller's failure
// path stays the single source of truth.
func (c *Client) BuildImageAt(ctx context.Context, contextDir, dockerfile, tag string, logFn func(line string)) error {
	if dockerfile == "" {
		dockerfile = "Dockerfile"
	}
	// Normalise to forward slashes — the tar entry names use them and the
	// Docker daemon expects the same.
	dockerfile = filepath.ToSlash(dockerfile)
	// Defence-in-depth: the dockerfile path is relative to contextDir and
	// is increasingly user/config-supplied (subfolder Dockerfiles). Reject
	// absolute paths and any `..` traversal at the boundary so a value like
	// "../../etc/passwd" can never be handed to the daemon's build options,
	// regardless of which builder backend resolves it.
	if filepath.IsAbs(dockerfile) || strings.HasPrefix(dockerfile, "/") ||
		dockerfile == ".." || strings.HasPrefix(dockerfile, "../") || strings.Contains(dockerfile, "/../") {
		return fmt.Errorf("docker build: invalid dockerfile path %q (must be relative to the build context, no traversal)", dockerfile)
	}
	// Create tar archive of the build context.
	pr, pw := io.Pipe()

	go func() {
		tw := tar.NewWriter(pw)
		err := filepath.Walk(contextDir, func(path string, info os.FileInfo, err error) error {
			if err != nil {
				return err
			}

			relPath, err := filepath.Rel(contextDir, path)
			if err != nil {
				return fmt.Errorf("rel path: %w", err)
			}
			relPath = filepath.ToSlash(relPath)

			if relPath == "." {
				return nil
			}

			header, err := tar.FileInfoHeader(info, "")
			if err != nil {
				return fmt.Errorf("tar header for %s: %w", relPath, err)
			}
			header.Name = relPath

			if err := tw.WriteHeader(header); err != nil {
				return fmt.Errorf("write tar header for %s: %w", relPath, err)
			}

			if info.IsDir() {
				return nil
			}

			// Per-file close, NOT defer. `defer file.Close()` inside the
			// WalkFunc only runs when the outer goroutine returns — for a
			// build context with thousands of files (node_modules-heavy
			// repo) that leaks one fd per file until the walk completes
			// and trips EMFILE on default ulimit=1024 systems.
			if err := streamFileIntoTar(tw, path, relPath); err != nil {
				return err
			}
			return nil
		})

		if closeErr := tw.Close(); closeErr != nil && err == nil {
			err = closeErr
		}
		pw.CloseWithError(err)
	}()

	// Pin the legacy builder explicitly. On Docker Engine 23+ BuildKit
	// is the default for the CLI, but the daemon honours the explicit
	// Version field on ImageBuildOptions. Legacy builder does NOT support
	// `RUN --mount=type=bind,source=/host` so a malicious Dockerfile
	// cannot mount host paths into the build context. Switching to
	// BuildKit later requires (a) Dockerfile-content validation to
	// reject bind-mount hints, or (b) an explicit per-workload opt-in.
	resp, err := c.api.ImageBuild(ctx, pr, client.ImageBuildOptions{
		Version:     build.BuilderV1,
		Dockerfile:  dockerfile,
		Tags:        []string{tag},
		Remove:      true,
		ForceRemove: true,
	})
	if err != nil {
		return fmt.Errorf("build image %s: %w", tag, err)
	}
	defer resp.Body.Close()

	// Drain the daemon's NDJSON stream to completion. The stream MUST
	// be read for the build to finish — closing the body early aborts
	// the build. We parse line-by-line into the {Stream, Error} shape
	// the daemon emits so an honest `{"error":"..."}` line surfaces
	// without false positives from informational `{"stream":"error
	// handling: retrying..."}` chatter that the old strings.Contains
	// path would have flagged.
	type buildLine struct {
		Stream string `json:"stream,omitempty"`
		Error  string `json:"error,omitempty"`
	}
	scanner := bufio.NewScanner(resp.Body)
	// Some build steps emit single lines exceeding the default 64 KiB
	// (e.g. a fat go-mod-download dump). Bump to 1 MiB so we don't
	// silently truncate and miss the trailing error line.
	scanner.Buffer(make([]byte, 64*1024), 1024*1024)
	var firstErr string
	for scanner.Scan() {
		line := scanner.Bytes()
		if len(line) == 0 {
			continue
		}
		var bl buildLine
		if err := json.Unmarshal(line, &bl); err != nil {
			// Non-JSON line — daemon shouldn't produce these, but
			// don't fail the build over a parse hiccup.
			continue
		}
		if bl.Error != "" && firstErr == "" {
			firstErr = bl.Error
		}
		if logFn != nil && bl.Stream != "" {
			logFn(bl.Stream)
		}
	}
	if err := scanner.Err(); err != nil {
		return fmt.Errorf("read build output for %s: %w", tag, err)
	}
	if firstErr != "" {
		return fmt.Errorf("build image %s: %s", tag, firstErr)
	}

	return nil
}

// streamFileIntoTar opens path, copies its contents into the tar writer
// under the given relPath header, and closes the file *before returning*
// — i.e. once per file, not deferred to the end of the entire walk.
// Extracted so the per-iteration close discipline is obvious at the
// callsite and the file handle isn't accidentally hoisted into the
// caller's defer stack via a future refactor.
func streamFileIntoTar(tw *tar.Writer, path, relPath string) error {
	file, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open %s: %w", path, err)
	}
	_, copyErr := io.Copy(tw, file)
	// Close BEFORE returning so the fd is released even on copy
	// failure. Capture both errors so the more-specific copy error
	// wins when both fire.
	if cerr := file.Close(); cerr != nil && copyErr == nil {
		copyErr = cerr
	}
	if copyErr != nil {
		return fmt.Errorf("copy %s to tar: %w", relPath, copyErr)
	}
	return nil
}
