package dockerfile

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// resolveContextDir picks the directory the Docker build context will
// be packed from, defensively. Returns an error rather than a directory
// outside the cloned tree even if ContextPath contains a tricky
// sequence — Validate already rejects ".." and leading "/", but
// EvalSymlinks here is the second wall.
//
// ctx may be "" (use cloneRoot as-is) or a relative subpath like
// "./api" or "services/api".
func resolveContextDir(cloneRoot, ctx string) (string, error) {
	cloneRoot, err := filepath.Abs(cloneRoot)
	if err != nil {
		return "", fmt.Errorf("abs cloneRoot: %w", err)
	}
	if real, err := filepath.EvalSymlinks(cloneRoot); err == nil {
		cloneRoot = real
	}
	if ctx == "" || ctx == "." || ctx == "./" {
		return cloneRoot, nil
	}
	candidate := filepath.Join(cloneRoot, filepath.FromSlash(ctx))
	candidate, err = filepath.Abs(candidate)
	if err != nil {
		return "", fmt.Errorf("abs candidate: %w", err)
	}
	// Resolve symlinks BEFORE the prefix check so a planted symlink
	// inside the clone cannot escape the build context.
	if real, err := filepath.EvalSymlinks(candidate); err == nil {
		candidate = real
	}
	if candidate != cloneRoot && !strings.HasPrefix(candidate, cloneRoot+string(filepath.Separator)) {
		return "", fmt.Errorf("context path %q escapes clone root", ctx)
	}
	info, err := os.Stat(candidate)
	if err != nil {
		return "", fmt.Errorf("stat context_path %q: %w", ctx, err)
	}
	if !info.IsDir() {
		return "", fmt.Errorf("context_path %q is not a directory", ctx)
	}
	return candidate, nil
}

// verifyDockerfileExists checks that the named Dockerfile is present in
// the resolved context. Returns a focused error for the operator instead
// of letting the daemon error out with a less obvious message later.
//
// dockerfilePath is the value from Config.DockerfilePath — relative to
// the context dir, "Dockerfile" by default.
func verifyDockerfileExists(contextDir, dockerfilePath string) error {
	if dockerfilePath == "" {
		dockerfilePath = "Dockerfile"
	}
	if strings.HasPrefix(dockerfilePath, "/") || strings.Contains(dockerfilePath, "..") {
		return fmt.Errorf("dockerfile_path %q must be relative and contain no '..'", dockerfilePath)
	}
	full := filepath.Join(contextDir, filepath.FromSlash(dockerfilePath))
	info, err := os.Stat(full)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("Dockerfile not found at %s/%s", filepath.Base(contextDir), dockerfilePath)
		}
		return fmt.Errorf("stat Dockerfile %q: %w", dockerfilePath, err)
	}
	if info.IsDir() {
		return fmt.Errorf("dockerfile_path %q points at a directory, not a file", dockerfilePath)
	}
	return nil
}

// sanitizeError clamps an error string before it lands in
// containers.extra_json (last_error) or echoes through an outbound
// notification webhook. Mirrors the static-plugin helper of the same
// name so both plugins agree on the surface area they expose to
// operators.
func sanitizeError(msg, accessToken string) string {
	return sanitizeErrorWithSecrets(msg, accessToken, nil)
}

// sanitizeErrorWithSecrets is the dockerfile-plugin-specific extension:
// when capturing container build/runtime logs into last_error we ALSO
// need to redact decrypted env-var values, because a malicious or
// debug-laden Dockerfile can `RUN echo $SECRET` and land a runtime
// secret in operator-readable state via /api/workloads/{id}/runtime-state.
//
// envKV is the same []string the docker client receives — entries shaped
// "KEY=VALUE". We split on the first '=' and redact every non-empty
// VALUE longer than 3 chars (shorter values produce too many false-
// positive substring matches against words like "is" / "of").
func sanitizeErrorWithSecrets(msg, accessToken string, envKV []string) string {
	if msg == "" {
		return ""
	}
	if accessToken != "" {
		msg = strings.ReplaceAll(msg, accessToken, "[REDACTED]")
	}
	for _, kv := range envKV {
		eq := strings.IndexByte(kv, '=')
		if eq < 0 {
			continue
		}
		value := kv[eq+1:]
		if len(value) < 4 {
			continue
		}
		msg = strings.ReplaceAll(msg, value, "[REDACTED]")
	}
	msg = strings.Map(func(r rune) rune {
		switch r {
		case '\n', '\r', '\t':
			return ' '
		}
		return r
	}, msg)
	const maxLen = 240
	if len(msg) > maxLen {
		// Rune-aware truncation: walk back to the previous rune
		// boundary so multi-byte chars at the cap don't tear.
		cut := maxLen
		for cut > 0 && !isRuneStart(msg[cut]) {
			cut--
		}
		msg = msg[:cut] + "…"
	}
	return msg
}

// isRuneStart reports whether b is a leading byte of a UTF-8 sequence.
// Used to walk back from a byte-offset cut to a rune boundary.
func isRuneStart(b byte) bool {
	return b&0xC0 != 0x80
}
