package signature

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"strings"

	"github.com/ProtonMail/go-crypto/openpgp"
	"github.com/ProtonMail/go-crypto/openpgp/clearsign"
	"github.com/ProtonMail/go-crypto/openpgp/packet"
	"github.com/gobwas/glob"

	"github.com/grafana/grafana/pkg/plugins"
	"github.com/grafana/grafana/pkg/plugins/config"
	"github.com/grafana/grafana/pkg/plugins/log"
	"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
)

var (
	runningWindows = runtime.GOOS == "windows"

	// toSlash is filepath.ToSlash, but can be overwritten in tests path separators cross-platform
	toSlash = filepath.ToSlash

	// fromSlash is filepath.FromSlash, but can be overwritten in tests path separators cross-platform
	fromSlash = filepath.FromSlash
)

// PluginManifest holds details for the file manifest
type PluginManifest struct {
	Plugin  string            `json:"plugin"`
	Version string            `json:"version"`
	KeyID   string            `json:"keyId"`
	Time    int64             `json:"time"`
	Files   map[string]string `json:"files"`

	// V2 supported fields
	ManifestVersion string                `json:"manifestVersion"`
	SignatureType   plugins.SignatureType `json:"signatureType"`
	SignedByOrg     string                `json:"signedByOrg"`
	SignedByOrgName string                `json:"signedByOrgName"`
	RootURLs        []string              `json:"rootUrls"`
}

func (m *PluginManifest) IsV2() bool {
	return strings.HasPrefix(m.ManifestVersion, "2.")
}

type Signature struct {
	kr  plugins.KeyRetriever
	cfg *config.PluginManagementCfg
	log log.Logger
}

var _ plugins.SignatureCalculator = &Signature{}

func ProvideService(cfg *config.PluginManagementCfg, kr plugins.KeyRetriever) *Signature {
	return NewCalculator(cfg, kr)
}

func NewCalculator(cfg *config.PluginManagementCfg, kr plugins.KeyRetriever) *Signature {
	return &Signature{
		kr:  kr,
		cfg: cfg,
		log: log.New("plugins.signature"),
	}
}

func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature {
	return &Signature{
		kr:  statickey.New(),
		cfg: cfg,
		log: log.New("plugins.signature"),
	}
}

// readPluginManifest attempts to read and verify the plugin manifest
// if any error occurs or the manifest is not valid, this will return an error
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*PluginManifest, error) {
	block, _ := clearsign.Decode(body)
	if block == nil {
		return nil, errors.New("unable to decode manifest")
	}

	// Convert to a well typed object
	var manifest PluginManifest
	err := json.Unmarshal(block.Plaintext, &manifest)
	if err != nil {
		return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
	}

	if err = s.validateManifest(ctx, manifest, block); err != nil {
		return nil, err
	}

	return &manifest, nil
}

var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned")

// ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS.
// If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned.
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) {
	f, err := pfs.Open("MANIFEST.txt")
	if err != nil {
		if errors.Is(err, plugins.ErrFileNotExist) {
			return nil, fmt.Errorf("%w: could not find a MANIFEST.txt", ErrSignatureTypeUnsigned)
		}
		return nil, fmt.Errorf("could not open MANIFEST.txt: %w", err)
	}
	defer func() {
		if f == nil {
			return
		}
		if err = f.Close(); err != nil {
			s.log.Warn("Failed to close plugin MANIFEST file", "error", err)
		}
	}()

	byteValue, err := io.ReadAll(f)
	if err != nil || len(byteValue) < 10 {
		return nil, fmt.Errorf("%w: MANIFEST.txt is invalid", ErrSignatureTypeUnsigned)
	}

	manifest, err := s.readPluginManifest(ctx, byteValue)
	if err != nil {
		return nil, err
	}
	return manifest, nil
}

func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
	if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists {
		return defaultSignature, nil
	}

	manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
	switch {
	case errors.Is(err, ErrSignatureTypeUnsigned):
		s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err)
		return plugins.Signature{
			Status: plugins.SignatureStatusUnsigned,
		}, nil
	case err != nil:
		s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err)
		return plugins.Signature{
			Status: plugins.SignatureStatusInvalid,
		}, nil
	}

	if !manifest.IsV2() {
		return plugins.Signature{
			Status: plugins.SignatureStatusInvalid,
		}, nil
	}

	fsFiles, err := plugin.FS.Files()
	if err != nil {
		return plugins.Signature{}, fmt.Errorf("files: %w", err)
	}
	if len(fsFiles) == 0 {
		s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID)
		return plugins.Signature{
			Status: plugins.SignatureStatusInvalid,
		}, nil
	}

	// Make sure the versions all match
	if manifest.Plugin != plugin.JSONData.ID || manifest.Version != plugin.JSONData.Info.Version {
		s.log.Debug("Plugin signature invalid because ID or Version mismatch", "pluginId", plugin.JSONData.ID, "manifestPluginId", manifest.Plugin, "pluginVersion", plugin.JSONData.Info.Version, "manifestPluginVersion", manifest.Version)
		return plugins.Signature{
			Status: plugins.SignatureStatusModified,
		}, nil
	}

	// Validate that plugin is running within defined root URLs
	if len(manifest.RootURLs) > 0 {
		if match, err := urlMatch(manifest.RootURLs, s.cfg.GrafanaAppURL, manifest.SignatureType); err != nil {
			s.log.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs)
			return plugins.Signature{}, err
		} else if !match {
			s.log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID,
				"appUrl", s.cfg.GrafanaAppURL, "rootUrls", manifest.RootURLs)
			return plugins.Signature{
				Status: plugins.SignatureStatusInvalid,
			}, nil
		}
	}

	manifestFiles := make(map[string]struct{}, len(manifest.Files))

	// Verify the manifest contents
	for p, hash := range manifest.Files {
		err = verifyHash(s.log, plugin, p, hash)
		if err != nil {
			s.log.Debug("Plugin signature invalid", "pluginId", plugin.JSONData.ID, "error", err)
			return plugins.Signature{
				Status: plugins.SignatureStatusModified,
			}, nil
		}

		manifestFiles[p] = struct{}{}
	}

	// Track files missing from the manifest
	var unsignedFiles []string
	for _, f := range fsFiles {
		// Ensure slashes are used, because MANIFEST.txt always uses slashes regardless of the filesystem
		f = toSlash(f)

		// Ignoring unsigned Chromium debug.log so it doesn't invalidate the signature for Renderer plugin running on Windows
		if runningWindows && plugin.JSONData.Type == plugins.TypeRenderer && filepath.Base(f) == "debug.log" {
			continue
		}

		if f == "MANIFEST.txt" {
			continue
		}
		if _, exists := manifestFiles[f]; !exists {
			unsignedFiles = append(unsignedFiles, f)
		}
	}

	if len(unsignedFiles) > 0 {
		s.log.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles)
		return plugins.Signature{
			Status: plugins.SignatureStatusModified,
		}, nil
	}

	s.log.Debug("Plugin signature valid", "id", plugin.JSONData.ID)
	return plugins.Signature{
		Status:     plugins.SignatureStatusValid,
		Type:       manifest.SignatureType,
		SigningOrg: manifest.SignedByOrgName,
	}, nil
}

func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
	path = fromSlash(path)

	// nolint:gosec
	// We can ignore the gosec G304 warning on this one because `path` is based
	// on the path provided in a manifest file for a plugin and not user input.
	f, err := plugin.FS.Open(path)
	if err != nil {
		if os.IsPermission(err) {
			mlog.Warn("Could not open plugin file due to lack of permissions", "plugin", plugin.JSONData.ID, "path", path)
			return errors.New("permission denied when attempting to read plugin file")
		}
		mlog.Warn("Plugin file listed in the manifest was not found", "plugin", plugin.JSONData.ID, "path", path)
		return errors.New("plugin file listed in the manifest was not found")
	}
	defer func() {
		if err := f.Close(); err != nil {
			mlog.Warn("Failed to close plugin file", "path", path, "error", err)
		}
	}()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return fmt.Errorf("could not calculate plugin file checksum. Path: %s. Error: %w", path, err)
	}
	sum := hex.EncodeToString(h.Sum(nil))
	if sum != hash {
		mlog.Warn("Plugin file checksum does not match signature checksum", "plugin", plugin.JSONData.ID, "path", path)
		return errors.New("plugin file checksum does not match signature checksum")
	}

	return nil
}

func urlMatch(specs []string, target string, signatureType plugins.SignatureType) (bool, error) {
	targetURL, err := url.Parse(target)
	if err != nil {
		return false, err
	}

	for _, spec := range specs {
		specURL, err := url.Parse(spec)
		if err != nil {
			return false, err
		}

		if specURL.Scheme == targetURL.Scheme && specURL.Host == targetURL.Host &&
			path.Clean(specURL.RequestURI()) == path.Clean(targetURL.RequestURI()) {
			return true, nil
		}

		if signatureType != plugins.SignatureTypePrivateGlob {
			continue
		}

		sp, err := glob.Compile(spec, '/', '.')
		if err != nil {
			return false, err
		}
		if match := sp.Match(target); match {
			return true, nil
		}
	}
	return false, nil
}

type invalidFieldErr struct {
	field string
}

func (r invalidFieldErr) Error() string {
	return fmt.Sprintf("valid manifest field %s is required", r.field)
}

func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, block *clearsign.Block) error {
	if len(m.Plugin) == 0 {
		return invalidFieldErr{field: "plugin"}
	}
	if len(m.Version) == 0 {
		return invalidFieldErr{field: "version"}
	}
	if len(m.KeyID) == 0 {
		return invalidFieldErr{field: "keyId"}
	}
	if m.Time == 0 {
		return invalidFieldErr{field: "time"}
	}
	if len(m.Files) == 0 {
		return invalidFieldErr{field: "files"}
	}
	if m.IsV2() {
		if len(m.SignedByOrg) == 0 {
			return invalidFieldErr{field: "signedByOrg"}
		}
		if len(m.SignedByOrgName) == 0 {
			return invalidFieldErr{field: "signedByOrgName"}
		}
		if !m.SignatureType.IsValid() {
			return fmt.Errorf("%s is not a valid signature type", m.SignatureType)
		}
	}

	return s.Verify(ctx, m.KeyID, block)
}

func (s *Signature) Verify(ctx context.Context, keyID string, block *clearsign.Block) error {
	publicKey, err := s.kr.GetPublicKey(ctx, keyID)
	if err != nil {
		return err
	}

	keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKey))
	if err != nil {
		return fmt.Errorf("%v: %w", "failed to parse public key", err)
	}

	if _, err = openpgp.CheckDetachedSignature(keyring,
		bytes.NewBuffer(block.Bytes),
		block.ArmoredSignature.Body, &packet.Config{}); err != nil {
		return fmt.Errorf("failed to check signature: %w", err)
	}

	return nil
}
