// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later

package plugin

import (
	"context"
	"crypto/tls"
	"fmt"
	"net"
	"strings"
	"time"

	"code.forgejo.org/forgejo/runner/v13/act/container"
	pluginv1alpha "code.forgejo.org/forgejo/runner/v13/act/plugin/proto/v1alpha"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/keepalive"
)

// grpc.NewClient is non-blocking; defaultDialTimeout is applied to the
// first RPC (health check) when the caller's context has no deadline.
const defaultDialTimeout = 10 * time.Second

// 32 MiB lifts the stock 4 MiB gRPC cap so plugins can emit larger Exec
// frames and CopyOut chunks without ResourceExhausted.
const defaultMaxMessageSize = 32 * 1024 * 1024

const (
	defaultKeepaliveTime    = 30 * time.Second
	defaultKeepaliveTimeout = 10 * time.Second
)

type ClientOption func(*clientOptions)

type clientOptions struct {
	tlsConfig        *tls.Config
	allowPlainTCP    bool
	dialTimeout      time.Duration
	maxMessageSize   int
	keepaliveTime    time.Duration
	keepaliveTimeout time.Duration
}

func WithTLS(cfg *tls.Config) ClientOption {
	return func(o *clientOptions) { o.tlsConfig = cfg }
}

// WithAllowPlainTCP allows TCP without TLS, for trusted localhost or tests.
func WithAllowPlainTCP() ClientOption {
	return func(o *clientOptions) { o.allowPlainTCP = true }
}

func WithDialTimeout(d time.Duration) ClientOption {
	return func(o *clientOptions) { o.dialTimeout = d }
}

// WithMaxMessageSize sets the per-call send/recv size limit in bytes.
func WithMaxMessageSize(bytes int) ClientOption {
	return func(o *clientOptions) { o.maxMessageSize = bytes }
}

// WithKeepalive sets the gRPC keepalive ping interval and ack timeout.
func WithKeepalive(interval, timeout time.Duration) ClientOption {
	return func(o *clientOptions) {
		o.keepaliveTime = interval
		o.keepaliveTimeout = timeout
	}
}

type Client struct {
	conn *grpc.ClientConn
	rpc  pluginv1alpha.BackendPluginClient
	// caps is captured once; restart the runner if a plugin changes capabilities.
	caps *pluginv1alpha.CapabilitiesResponse
}

func resolveOptions(options []ClientOption) *clientOptions {
	cfg := &clientOptions{
		dialTimeout:      defaultDialTimeout,
		maxMessageSize:   defaultMaxMessageSize,
		keepaliveTime:    defaultKeepaliveTime,
		keepaliveTimeout: defaultKeepaliveTimeout,
	}
	for _, opt := range options {
		opt(cfg)
	}
	return cfg
}

// withDialTimeout applies cfg.dialTimeout when the caller's context has none, so
// a non-blocking grpc.NewClient still fails fast on the first RPC.
func withDialTimeout(ctx context.Context, cfg *clientOptions) (context.Context, context.CancelFunc) {
	if _, ok := ctx.Deadline(); ok {
		return ctx, func() {}
	}
	return context.WithTimeout(ctx, cfg.dialTimeout)
}

// dial opens a connection to a plugin and confirms its health service is
// SERVING before the caller issues RPCs over it.
func dial(ctx context.Context, address string, cfg *clientOptions) (*grpc.ClientConn, error) {
	isUnix := strings.HasPrefix(address, "unix://")

	creds, err := transportCredentials(isUnix, cfg)
	if err != nil {
		return nil, err
	}

	opts := []grpc.DialOption{
		grpc.WithTransportCredentials(creds),
		grpc.WithDefaultCallOptions(
			grpc.MaxCallRecvMsgSize(cfg.maxMessageSize),
			grpc.MaxCallSendMsgSize(cfg.maxMessageSize),
		),
		grpc.WithKeepaliveParams(keepalive.ClientParameters{
			Time:                cfg.keepaliveTime,
			Timeout:             cfg.keepaliveTimeout,
			PermitWithoutStream: false,
		}),
	}
	if isUnix {
		socketPath := strings.TrimPrefix(address, "unix://")
		opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
			return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
		}))
		address = "passthrough:///" + socketPath
	}

	conn, err := grpc.NewClient(address, opts...)
	if err != nil {
		return nil, fmt.Errorf("dial plugin at %s: %w", address, err)
	}

	healthClient := grpc_health_v1.NewHealthClient(conn)
	healthResp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
	if err != nil {
		conn.Close()
		return nil, fmt.Errorf("health check plugin at %s: %w", address, err)
	}
	if got := healthResp.GetStatus(); got != grpc_health_v1.HealthCheckResponse_SERVING {
		conn.Close()
		return nil, fmt.Errorf("health check plugin at %s: status %s", address, got)
	}

	return conn, nil
}

// NewClient connects to a BackendPlugin: a plugin that owns the whole execution
// environment and every container operation within it.
func NewClient(ctx context.Context, address string, options ...ClientOption) (*Client, error) {
	cfg := resolveOptions(options)
	ctx, cancel := withDialTimeout(ctx, cfg)
	defer cancel()

	conn, err := dial(ctx, address, cfg)
	if err != nil {
		return nil, err
	}

	rpc := pluginv1alpha.NewBackendPluginClient(conn)
	caps, err := rpc.Capabilities(ctx, &pluginv1alpha.CapabilitiesRequest{})
	if err != nil {
		conn.Close()
		return nil, fmt.Errorf("get capabilities from plugin at %s: %w", address, err)
	}
	if err := validateCapabilities(caps); err != nil {
		conn.Close()
		return nil, fmt.Errorf("plugin at %s: %w", address, err)
	}

	return &Client{
		conn: conn,
		rpc:  rpc,
		caps: caps,
	}, nil
}

func transportCredentials(isUnix bool, cfg *clientOptions) (credentials.TransportCredentials, error) {
	switch {
	case cfg.tlsConfig != nil:
		return credentials.NewTLS(cfg.tlsConfig), nil
	case isUnix, cfg.allowPlainTCP:
		return insecure.NewCredentials(), nil
	default:
		return nil, fmt.Errorf("plugin: TCP address requires WithTLS or WithAllowPlainTCP")
	}
}

func validateCapabilities(caps *pluginv1alpha.CapabilitiesResponse) error {
	missing := []string{}
	if caps.GetName() == "" {
		missing = append(missing, "name")
	}
	if len(missing) > 0 {
		return fmt.Errorf("capabilities response missing required fields: %s", strings.Join(missing, ", "))
	}
	return nil
}

func (c *Client) Capabilities() *pluginv1alpha.CapabilitiesResponse {
	return c.caps
}

func (c *Client) NewEnvironment(input *container.NewContainerInput, backendOpts map[string]string, labelArg string, timeout time.Duration) container.ExecutionsEnvironment {
	return &pluginEnvironment{
		client:      c.rpc,
		caps:        c.caps,
		backendOpts: backendOpts,
		labelArg:    labelArg,
		timeout:     timeout,
		input:       input,
		stdout:      input.Stdout,
		stderr:      input.Stderr,
	}
}

func (c *Client) Close() error {
	return c.conn.Close()
}
