// FIXME(jsternberg): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.21

package command

import (
	"context"
	"fmt"
	"io/fs"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strings"
	"unicode"

	"github.com/pkg/errors"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

const (
	otelContextFieldName     string = "otel"
	otelExporterOTLPEndpoint string = "OTEL_EXPORTER_OTLP_ENDPOINT"
	debugEnvVarPrefix        string = "DOCKER_CLI_"
)

// dockerExporterOTLPEndpoint retrieves the OTLP endpoint used for the docker reporter
// from the current context.
func dockerExporterOTLPEndpoint(cli Cli) (endpoint string, secure bool) {
	meta, err := cli.ContextStore().GetMetadata(cli.CurrentContext())
	if err != nil {
		otel.Handle(err)
		return "", false
	}

	var otelCfg any
	switch m := meta.Metadata.(type) {
	case DockerContext:
		otelCfg = m.AdditionalFields[otelContextFieldName]
	case map[string]any:
		otelCfg = m[otelContextFieldName]
	}

	if otelCfg != nil {
		otelMap, ok := otelCfg.(map[string]any)
		if !ok {
			otel.Handle(errors.Errorf(
				"unexpected type for field %q: %T (expected: %T)",
				otelContextFieldName,
				otelCfg,
				otelMap,
			))
		}
		// keys from https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/
		endpoint, _ = otelMap[otelExporterOTLPEndpoint].(string)
	}

	// Override with env var value if it exists AND IS SET
	// (ignore otel defaults for this override when the key exists but is empty)
	if override := os.Getenv(debugEnvVarPrefix + otelExporterOTLPEndpoint); override != "" {
		endpoint = override
	}

	if endpoint == "" {
		return "", false
	}

	// Parse the endpoint. The docker config expects the endpoint to be
	// in the form of a URL to match the environment variable, but this
	// option doesn't correspond directly to WithEndpoint.
	//
	// We pretend we're the same as the environment reader.
	u, err := url.Parse(endpoint)
	if err != nil {
		otel.Handle(errors.Errorf("docker otel endpoint is invalid: %s", err))
		return "", false
	}

	switch u.Scheme {
	case "unix":
		endpoint = unixSocketEndpoint(u)
	case "https":
		secure = true
		fallthrough
	case "http":
		endpoint = path.Join(u.Host, u.Path)
	}
	return endpoint, secure
}

func dockerSpanExporter(ctx context.Context, cli Cli) []sdktrace.TracerProviderOption {
	endpoint, secure := dockerExporterOTLPEndpoint(cli)
	if endpoint == "" {
		return nil
	}

	opts := []otlptracegrpc.Option{
		otlptracegrpc.WithEndpoint(endpoint),
	}
	if !secure {
		opts = append(opts, otlptracegrpc.WithInsecure())
	}

	exp, err := otlptracegrpc.New(ctx, opts...)
	if err != nil {
		otel.Handle(err)
		return nil
	}
	return []sdktrace.TracerProviderOption{sdktrace.WithBatcher(exp, sdktrace.WithExportTimeout(exportTimeout))}
}

func dockerMetricExporter(ctx context.Context, cli Cli) []sdkmetric.Option {
	endpoint, secure := dockerExporterOTLPEndpoint(cli)
	if endpoint == "" {
		return nil
	}

	opts := []otlpmetricgrpc.Option{
		otlpmetricgrpc.WithEndpoint(endpoint),
	}
	if !secure {
		opts = append(opts, otlpmetricgrpc.WithInsecure())
	}

	exp, err := otlpmetricgrpc.New(ctx, opts...)
	if err != nil {
		otel.Handle(err)
		return nil
	}
	return []sdkmetric.Option{sdkmetric.WithReader(newCLIReader(exp))}
}

// unixSocketEndpoint converts the unix scheme from URL to
// an OTEL endpoint that can be used with the OTLP exporter.
//
// The OTLP exporter handles unix sockets in a strange way.
// It seems to imply they can be used as an environment variable
// and are handled properly, but they don't seem to be as the behavior
// of the environment variable is to strip the scheme from the endpoint
// while the underlying implementation needs the scheme to use the
// correct resolver.
func unixSocketEndpoint(u *url.URL) string {
	// GRPC does not allow host to be used.
	socketPath := u.Path

	// If we are on windows and we have an absolute path
	// that references a letter drive, check to see if the
	// WSL equivalent path exists and we should use that instead.
	if isWsl() {
		if p := wslSocketPath(socketPath, os.DirFS("/")); p != "" {
			socketPath = p
		}
	}
	// Enforce that we are using forward slashes.
	return "unix://" + filepath.ToSlash(socketPath)
}

// wslSocketPath will convert the referenced URL to a WSL-compatible
// path and check if that path exists. If the path exists, it will
// be returned.
func wslSocketPath(s string, f fs.FS) string {
	if p := toWslPath(s); p != "" {
		if _, err := stat(p, f); err == nil {
			return "/" + p
		}
	}
	return ""
}

// toWslPath converts the referenced URL to a WSL-compatible
// path if this looks like a Windows absolute path.
//
// If no drive is in the URL, defaults to the C drive.
func toWslPath(s string) string {
	drive, p, ok := parseUNCPath(s)
	if !ok {
		return ""
	}
	return fmt.Sprintf("mnt/%s%s", strings.ToLower(drive), p)
}

func parseUNCPath(s string) (drive, p string, ok bool) {
	// UNC paths use backslashes but we're using forward slashes
	// so also enforce that here.
	//
	// In reality, this should have been enforced much earlier
	// than here since backslashes aren't allowed in URLs, but
	// we're going to code defensively here.
	s = filepath.ToSlash(s)

	const uncPrefix = "//./"
	if !strings.HasPrefix(s, uncPrefix) {
		// Not a UNC path.
		return "", "", false
	}
	s = s[len(uncPrefix):]

	parts := strings.SplitN(s, "/", 2)
	if len(parts) != 2 {
		// Not enough components.
		return "", "", false
	}

	drive, ok = splitWindowsDrive(parts[0])
	if !ok {
		// Not a windows drive.
		return "", "", false
	}
	return drive, "/" + parts[1], true
}

// splitWindowsDrive checks if the string references a windows
// drive (such as c:) and returns the drive letter if it is.
func splitWindowsDrive(s string) (string, bool) {
	if b := []rune(s); len(b) == 2 && unicode.IsLetter(b[0]) && b[1] == ':' {
		return string(b[0]), true
	}
	return "", false
}

func stat(p string, f fs.FS) (fs.FileInfo, error) {
	if f, ok := f.(fs.StatFS); ok {
		return f.Stat(p)
	}

	file, err := f.Open(p)
	if err != nil {
		return nil, err
	}

	defer file.Close()
	return file.Stat()
}

func isWsl() bool {
	return os.Getenv("WSL_DISTRO_NAME") != ""
}
