/*
Copyright 2023 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
    http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implieout.
See the License for the specific language governing permissions and
limitations under the License.
*/

package wasm

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"reflect"
	"strconv"
	"strings"
	"sync/atomic"

	"github.com/stealthrocket/wasi-go/imports/wasi_http"
	"github.com/stealthrocket/wasi-go/imports/wasi_http/default_http"
	"github.com/tetratelabs/wazero"
	"github.com/tetratelabs/wazero/api"
	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"

	"github.com/dapr/components-contrib/bindings"
	"github.com/dapr/components-contrib/internal/wasm"
	"github.com/dapr/components-contrib/metadata"
	"github.com/dapr/kit/logger"
)

// ExecuteOperation is defined here as it isn't in the bindings package.
const ExecuteOperation bindings.OperationKind = "execute"

type outputBinding struct {
	logger        logger.Logger
	runtimeConfig wazero.RuntimeConfig

	meta    *wasm.InitMetadata
	runtime wazero.Runtime
	module  wazero.CompiledModule

	instanceCounter atomic.Uint64
}

var (
	_ bindings.OutputBinding = (*outputBinding)(nil)
	_ io.Closer              = (*outputBinding)(nil)
)

func NewWasmOutput(logger logger.Logger) bindings.OutputBinding {
	return &outputBinding{
		logger: logger,

		// The below ensures context cancels in-flight wasm functions.
		runtimeConfig: wazero.NewRuntimeConfig().
			WithCloseOnContextDone(true),
	}
}

func (out *outputBinding) Init(ctx context.Context, metadata bindings.Metadata) (err error) {
	if out.meta, err = wasm.GetInitMetadata(ctx, metadata.Base); err != nil {
		return fmt.Errorf("wasm: failed to parse metadata: %w", err)
	}

	// Create the runtime, which when closed releases any resources associated with it.
	out.runtime = wazero.NewRuntimeWithConfig(ctx, out.runtimeConfig)

	// Compile the module, which reduces execution time of Invoke
	out.module, err = out.runtime.CompileModule(ctx, out.meta.Guest)
	if err != nil {
		_ = out.runtime.Close(context.Background())
		return fmt.Errorf("wasm: error compiling binary: %w", err)
	}

	imports := detectImports(out.module.ImportedFunctions())

	if _, found := imports[modeWasiP1]; found {
		_, err = wasi_snapshot_preview1.Instantiate(ctx, out.runtime)
	}
	if err != nil {
		_ = out.runtime.Close(context.Background())
		return fmt.Errorf("wasm: error instantiating host wasi functions: %w", err)
	}
	if _, found := imports[modeWasiHTTP]; found {
		if out.meta.StrictSandbox {
			_ = out.runtime.Close(context.Background())
			return fmt.Errorf("can not instantiate wasi-http with strict sandbox")
		}
		err = wasi_http.Instantiate(ctx, out.runtime)
	}
	if err != nil {
		_ = out.runtime.Close(context.Background())
		return fmt.Errorf("wasm: error instantiating host wasi-http functions: %w", err)
	}
	return nil
}

func (out *outputBinding) Invoke(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
	guestName := out.meta.GuestName
	if guestName == "" {
		guestName = out.module.Name()
	}

	// Currently, concurrent modules can conflict on name. Make sure we have
	// a unique one.
	instanceNum := out.instanceCounter.Add(1)
	instanceName := guestName + "-" + strconv.FormatUint(instanceNum, 10)
	moduleConfig := wasm.NewModuleConfig(out.meta).WithName(instanceName)

	// Only assign STDIN if it is present in the request.
	if len(req.Data) > 0 {
		moduleConfig = moduleConfig.WithStdin(bytes.NewReader(req.Data))
	}

	// Any STDOUT is returned as a result: capture it into a buffer.
	var stdout bytes.Buffer
	moduleConfig = moduleConfig.WithStdout(&stdout)

	// Set the program name to the binary name
	argsSlice := []string{guestName}

	// Get any remaining args from configuration
	if args := req.Metadata["args"]; args != "" {
		argsSlice = append(argsSlice, strings.Split(args, ",")...)
	}
	moduleConfig = moduleConfig.WithArgs(argsSlice...)

	// Instantiating executes the guest's main function (exported as _start).
	mod, err := out.runtime.InstantiateModule(ctx, out.module, moduleConfig)

	// WASI typically calls proc_exit which exits the module, but just in case
	// it doesn't, close the module manually.
	_ = mod.Close(ctx)

	// Return STDOUT if there was no error.
	if err == nil {
		return &bindings.InvokeResponse{Data: stdout.Bytes()}, nil
	}
	return nil, err
}

func (out *outputBinding) Operations() []bindings.OperationKind {
	return []bindings.OperationKind{
		ExecuteOperation,
	}
}

// Close implements io.Closer
func (out *outputBinding) Close() error {
	// wazero's runtime closes everything.
	if rt := out.runtime; rt != nil {
		return rt.Close(context.Background())
	}
	return nil
}

// In the future
const (
	modeDefault importMode = iota
	modeWasiP1
	modeWasiHTTP
)

type importMode uint

func detectImports(imports []api.FunctionDefinition) map[importMode]bool {
	result := make(map[importMode]bool)
	for _, f := range imports {
		moduleName, _, _ := f.Import()
		switch moduleName {
		case wasi_snapshot_preview1.ModuleName:
			result[modeWasiP1] = true
		case default_http.ModuleName:
			result[modeWasiHTTP] = true
		}
	}
	return result
}

// GetComponentMetadata returns the metadata of the component.
func (out *outputBinding) GetComponentMetadata() (metadataInfo metadata.MetadataMap) {
	metadataStruct := wasm.InitMetadata{}
	metadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo, metadata.BindingType)
	return
}
