package plugins

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	"github.com/google/uuid"
)

const maxPluginOutputBytes = 1024 * 1024

var pluginEnvironmentKeys = []string{
	"PATH",
	"SystemRoot",
	"WINDIR",
	"TEMP",
	"TMP",
	"TMPDIR",
	"LANG",
	"LC_ALL",
	"SSL_CERT_FILE",
	"SSL_CERT_DIR",
}

type Caller interface {
	Call(ctx context.Context, manifest Manifest, method string, params any, result any) error
}

type ProcessCaller struct {
	timeout time.Duration
	logger  *slog.Logger
}

type rpcRequest struct {
	JSONRPC string         `json:"jsonrpc"`
	ID      string         `json:"id"`
	Method  string         `json:"method"`
	Params  any            `json:"params,omitempty"`
	Meta    map[string]any `json:"meta,omitempty"`
}

type rpcResponse struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      string          `json:"id"`
	Result  json.RawMessage `json:"result,omitempty"`
	Error   *rpcError       `json:"error,omitempty"`
}

type rpcError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
	Data    any    `json:"data,omitempty"`
}

func NewProcessCaller(timeout time.Duration, logger *slog.Logger) *ProcessCaller {
	if timeout <= 0 {
		timeout = 30 * time.Second
	}
	return &ProcessCaller{timeout: timeout, logger: logger}
}

func (c *ProcessCaller) Call(ctx context.Context, manifest Manifest, method string, params any, result any) error {
	switch manifest.Runtime {
	case RuntimeProcess, RuntimeJSONRPC:
	default:
		return fmt.Errorf("%w: %s", ErrRuntimeUnsupported, manifest.Runtime)
	}
	entry, pluginDir, err := secureProcessEntry(manifest)
	if err != nil {
		return err
	}
	request := rpcRequest{
		JSONRPC: "2.0",
		ID:      uuid.NewString(),
		Method:  method,
		Params:  params,
		Meta: map[string]any{
			"plugin_id":      manifest.ID,
			"plugin_version": manifest.Version,
			"capabilities":   manifest.Capabilities,
		},
	}
	raw, err := json.Marshal(request)
	if err != nil {
		return err
	}

	callCtx, cancel := context.WithTimeout(ctx, c.timeout)
	defer cancel()

	cmd := exec.CommandContext(callCtx, entry)
	cmd.Dir = pluginDir
	cmd.Env = sanitizedPluginEnvironment()
	cmd.Stdin = bytes.NewReader(raw)
	stdout := newBoundedBuffer(maxPluginOutputBytes)
	stderr := newBoundedBuffer(maxPluginOutputBytes)
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		message := strings.TrimSpace(stderr.String())
		if message == "" {
			message = err.Error()
		}
		if errors.Is(callCtx.Err(), context.DeadlineExceeded) {
			return fmt.Errorf("plugin %s call %s timed out: %w", manifest.ID, method, callCtx.Err())
		}
		return fmt.Errorf("plugin %s call %s failed: %s", manifest.ID, method, message)
	}
	if stdout.overflow || stderr.overflow {
		return fmt.Errorf("plugin %s call %s exceeded output limit", manifest.ID, method)
	}
	var response rpcResponse
	if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
		return fmt.Errorf("plugin %s returned invalid json-rpc response: %w: %s", manifest.ID, err, strings.TrimSpace(stdout.String()))
	}
	if response.Error != nil {
		return fmt.Errorf("plugin %s %s error %d: %s", manifest.ID, method, response.Error.Code, response.Error.Message)
	}
	if result == nil || len(response.Result) == 0 {
		return nil
	}
	if err := json.Unmarshal(response.Result, result); err != nil {
		return fmt.Errorf("plugin %s %s result decode failed: %w", manifest.ID, method, err)
	}
	if c.logger != nil {
		c.logger.Debug("plugin call completed", "plugin", manifest.ID, "method", method)
	}
	return nil
}

func secureProcessEntry(manifest Manifest) (string, string, error) {
	entry, err := manifest.EntryForCurrentPlatform()
	if err != nil {
		return "", "", err
	}
	pluginDir, err := filepath.Abs(manifest.Dir)
	if err != nil {
		return "", "", err
	}
	pluginDir, err = filepath.EvalSymlinks(pluginDir)
	if err != nil {
		return "", "", fmt.Errorf("resolve plugin directory: %w", err)
	}
	if !filepath.IsAbs(entry) {
		entry = filepath.Join(pluginDir, entry)
	}
	entry = resolveProcessEntry(entry)
	entry, err = filepath.EvalSymlinks(entry)
	if err != nil {
		return "", "", fmt.Errorf("resolve plugin entry: %w", err)
	}
	if err := ensureWithin(pluginDir, entry); err != nil {
		return "", "", err
	}
	info, err := os.Stat(entry)
	if err != nil {
		return "", "", err
	}
	if !info.Mode().IsRegular() {
		return "", "", fmt.Errorf("%w: plugin entry is not a regular file", ErrInvalidManifest)
	}
	if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
		return "", "", fmt.Errorf("%w: plugin entry is not executable", ErrInvalidManifest)
	}
	return entry, pluginDir, nil
}

func sanitizedPluginEnvironment() []string {
	environment := make([]string, 0, len(pluginEnvironmentKeys))
	for _, key := range pluginEnvironmentKeys {
		if value, ok := os.LookupEnv(key); ok {
			environment = append(environment, key+"="+value)
		}
	}
	return environment
}

type boundedBuffer struct {
	bytes.Buffer
	limit    int
	overflow bool
}

func newBoundedBuffer(limit int) boundedBuffer {
	return boundedBuffer{limit: limit}
}

func (b *boundedBuffer) Write(p []byte) (int, error) {
	written := len(p)
	remaining := b.limit - b.Len()
	if remaining <= 0 {
		b.overflow = true
		return written, nil
	}
	if len(p) > remaining {
		b.overflow = true
		p = p[:remaining]
	}
	_, _ = b.Buffer.Write(p)
	return written, nil
}

func resolveProcessEntry(entry string) string {
	if _, err := os.Stat(entry); err == nil {
		return entry
	}
	if runtime.GOOS == "windows" && filepath.Ext(entry) == "" {
		candidate := entry + ".exe"
		if _, err := os.Stat(candidate); err == nil {
			return candidate
		}
	}
	return entry
}
