// Copyright 2020-2026 Buf Technologies, Inc.
//
// 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 implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package bufplugin

import (
	"bytes"
	"fmt"
	"strings"
	"sync"

	"github.com/bufbuild/buf/private/bufpkg/bufparse"
	"github.com/bufbuild/buf/private/pkg/cas"
	"github.com/bufbuild/buf/private/pkg/syserror"
	"github.com/google/uuid"
)

// Plugin presents a BSR plugin.
type Plugin interface {
	// OpaqueID returns an unstructured ID that can uniquely identify a Plugin
	// relative to the Workspace.
	//
	// An OpaqueID's structure should not be relied upon, and is not a
	// globally-unique identifier. It's uniqueness property only applies to
	// the lifetime of the Plugin, and only within the Workspace the Plugin
	// is defined in.
	//
	// If two Plugins have the same Name and Args, they will have the same OpaqueID.
	OpaqueID() string
	// Name returns the name of the Plugin.
	//  - For local Plugins, this is the path to the executable binary.
	//  - For local Wasm Plugins, this is the path to the Wasm binary.
	//  - For remote Plugins, this is the FullName of the Plugin in the form
	//    remote/owner/name.
	//
	// This is never empty.
	Name() string
	// Args returns the arguments to invoke the Plugin.
	//
	// May be nil.
	Args() []string
	// FullName returns the full name of the Plugin.
	//
	// May be nil. Callers should not rely on this value being present.
	// However, this is always present for remote Plugins.
	//
	// Use OpaqueID as an always-present identifier.
	FullName() bufparse.FullName
	// CommitID returns the BSR ID of the Commit.
	//
	// It is up to the caller to convert this to a dashless ID when necessary.
	//
	// May be empty, that is CommitID() == uuid.Nil may be true.
	// Callers should not rely on this value being present.
	//
	// If FullName is nil, this will always be empty.
	CommitID() uuid.UUID
	// Description returns a human-readable description of the Plugin.
	//
	// This is used to construct descriptive error messages pointing to configured plugins.
	//
	// This will never be empty. If a description was not explicitly set, this falls back to
	// OpaqueID.
	Description() string
	// Digest returns the Plugin digest for the given DigestType.
	//
	// Note this is *not* a cas.Digest - this is a Digest.
	// cas.Digests are a lower-level type that just deal in terms of
	// files and content. A Digest is a specific algorithm applied to the
	// content of a Plugin.
	//
	// Will return an error if the Plugin is not a Wasm Plugin.
	Digest(DigestType) (Digest, error)
	// Data returns the bytes of the Plugin as a Wasm module.
	//
	// This is the raw bytes of the Wasm module in an uncompressed form.
	//
	// Will return an error if the Plugin is not a Wasm Plugin.
	Data() ([]byte, error)
	// IsWasm returns true if the Plugin is a Wasm Plugin.
	//
	// Plugins are either Wasm or local.
	//
	// A Wasm Plugin is a Plugin that is a Wasm module. Wasm Plugins are invoked
	// with the wasm.Runtime. The Plugin will have Data and will be able to
	// calculate Digests.
	//
	// Wasm Plugins will always have Data.
	IsWasm() bool
	// IsLocal returns true if the Plugin is a local Plugin.
	//
	// Plugins are either local or remote.
	//
	// A local Plugin is one that is built from sources from the "local context",
	// such as a Workspace. Local Plugins are important for understanding what Plugins
	// to push.
	//
	// Remote Plugins will always have FullNames.
	IsLocal() bool

	isPlugin()
}

// NewLocalPlugin returns a new Plugin for a local plugin.
//
// The name is the path to the executable binary.
// The args are the arguments to invoke the Plugin. These are passed to the Plugin
// as command line arguments.
func NewLocalPlugin(
	name string,
	args []string,
) (Plugin, error) {
	return newPlugin(
		"", // description
		nil,
		name,
		args,
		uuid.Nil, // commitID
		false,    // isWasm
		true,     // isLocal
		nil,      // getData
	)
}

// NewLocalWasmPlugin returns a new Plugin for a local Wasm plugin.
//
// The pluginFullName may be nil.
// The name is the path to the Wasm plugin and must end with .wasm.
// The args are the arguments to the Wasm plugin. These are passed to the Wasm plugin
// as command line arguments.
// The getData function is called to get the bytes of the Wasm plugin.
// This is the raw bytes of the Wasm module in an uncompressed form.
func NewLocalWasmPlugin(
	pluginFullName bufparse.FullName,
	name string,
	args []string,
	getData func() ([]byte, error),
) (Plugin, error) {
	return newPlugin(
		"", // description
		pluginFullName,
		name,
		args,
		uuid.Nil, // commitID
		true,     // isWasm
		true,     // isLocal
		getData,
	)
}

// NewRemoteWasmPlugin returns a new Plugin for a remote Wasm plugin.
//
// The pluginFullName is the remote reference to the plugin.
// The args are the arguments to the remote plugin. These are passed to the remote plugin
// as command line arguments.
// The commitID is the BSR ID of the Commit.
// It is up to the caller to convert this to a dashless ID when necessary.
// The getData function is called to get the bytes of the Wasm plugin.
// This is the raw bytes of the Wasm module in an uncompressed form.
func NewRemoteWasmPlugin(
	pluginFullName bufparse.FullName,
	args []string,
	commitID uuid.UUID,
	getData func() ([]byte, error),
) (Plugin, error) {
	return newPlugin(
		"", // description
		pluginFullName,
		pluginFullName.String(),
		args,
		commitID,
		true,  // isWasm
		false, // isLocal
		getData,
	)
}

// *** PRIVATE ***

type plugin struct {
	description    string
	pluginFullName bufparse.FullName
	name           string
	args           []string
	commitID       uuid.UUID
	isWasm         bool
	isLocal        bool
	getData        func() ([]byte, error)

	digestTypeToGetDigest map[DigestType]func() (Digest, error)
}

func newPlugin(
	description string,
	pluginFullName bufparse.FullName,
	name string,
	args []string,
	commitID uuid.UUID,
	isWasm bool,
	isLocal bool,
	getData func() ([]byte, error),
) (*plugin, error) {
	if name == "" {
		return nil, syserror.New("name not present when constructing a Plugin")
	}
	if isWasm && getData == nil {
		return nil, syserror.Newf("getData not present when constructing a Wasm Plugin")
	}
	if !isLocal && pluginFullName == nil {
		return nil, syserror.New("pluginFullName not present when constructing a remote Plugin")
	}
	if !isLocal && !isWasm {
		return nil, syserror.New("remote non-Wasm Plugins are not supported")
	}
	if isLocal && commitID != uuid.Nil {
		return nil, syserror.New("commitID present when constructing a local Plugin")
	}
	if pluginFullName == nil && commitID != uuid.Nil {
		return nil, syserror.New("pluginFullName not present and commitID present when constructing a remote Plugin")
	}
	plugin := &plugin{
		description:    description,
		pluginFullName: pluginFullName,
		name:           name,
		args:           args,
		commitID:       commitID,
		isWasm:         isWasm,
		isLocal:        isLocal,
		getData:        sync.OnceValues(getData),
	}
	plugin.digestTypeToGetDigest = newSyncOnceValueDigestTypeToGetDigestFuncForPlugin(plugin)
	return plugin, nil
}

func (p *plugin) OpaqueID() string {
	return strings.Join(append([]string{p.name}, p.args...), " ")
}

func (p *plugin) Name() string {
	return p.name
}

func (p *plugin) Args() []string {
	return p.args
}

func (p *plugin) FullName() bufparse.FullName {
	return p.pluginFullName
}

func (p *plugin) CommitID() uuid.UUID {
	return p.commitID
}

func (p *plugin) Description() string {
	if p.description != "" {
		return p.description
	}
	return p.OpaqueID()
}

func (p *plugin) Data() ([]byte, error) {
	if !p.isWasm {
		return nil, fmt.Errorf("Plugin is not a Wasm Plugin")
	}
	return p.getData()
}

func (p *plugin) Digest(digestType DigestType) (Digest, error) {
	getDigest, ok := p.digestTypeToGetDigest[digestType]
	if !ok {
		return nil, syserror.Newf("DigestType %v was not in plugin.digestTypeToGetDigest", digestType)
	}
	return getDigest()
}

func (p *plugin) IsWasm() bool {
	return p.isWasm
}

func (p *plugin) IsLocal() bool {
	return p.isLocal
}

func (p *plugin) isPlugin() {}

func newSyncOnceValueDigestTypeToGetDigestFuncForPlugin(plugin *plugin) map[DigestType]func() (Digest, error) {
	m := make(map[DigestType]func() (Digest, error))
	for digestType := range digestTypeToString {
		m[digestType] = sync.OnceValues(newGetDigestFuncForPluginAndDigestType(plugin, digestType))
	}
	return m
}

func newGetDigestFuncForPluginAndDigestType(plugin *plugin, digestType DigestType) func() (Digest, error) {
	return func() (Digest, error) {
		data, err := plugin.getData()
		if err != nil {
			return nil, err
		}
		casDigest, err := cas.NewDigestForContent(cas.DigestTypeShake256, bytes.NewReader(data))
		if err != nil {
			return nil, err
		}
		return NewDigest(digestType, casDigest)
	}
}
