//  Copyright 2024 Google LLC
//
//  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
//
//     https://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 manager

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/gob"
	"encoding/hex"
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"time"

	"github.com/GoogleCloudPlatform/galog"
	acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp"
	"github.com/GoogleCloudPlatform/google-guest-agent/internal/boundedlist"
	"github.com/GoogleCloudPlatform/google-guest-agent/internal/retry"
	pb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/proto"
	dpb "google.golang.org/protobuf/types/known/durationpb"
	structpb "google.golang.org/protobuf/types/known/structpb"
)

const (
	// default timeouts for gRPC calls with plugin so Guest Agent does not end up
	// waiting for a response forever.
	defaultApplyRPCTimeout  = time.Second * 5
	defaultStatusRPCTimeout = time.Second * 2
	// defaultConnectTimeoutTries is the default number of tries for connecting to
	// a plugin. The plugin manager will wait for one second between each try.
	defaultConnectTimeoutTries = 15
)

// Plugin struct represents the plugin information.
type Plugin struct {
	// Name is the current plugin name.
	Name string
	// Revision is the current plugin revision.
	Revision string
	// Address is the current address plugin is listening on.
	Address string
	// InstallPath is the path to the directory where plugin is
	// installed/unpacked.
	InstallPath string
	// EntryPath is the path to the plugin/binary entry point from which its spun
	// up.
	EntryPath string
	// Protocol is the protocol used for communication with the plugin.
	Protocol string
	// clientMu protects client connection with the plugin.
	clientMu sync.RWMutex
	// client is grpc client connection with the plugin.
	client *grpc.ClientConn
	// Manifest is plugin configuration defining various agent/plugin behavior.
	Manifest *Manifest
	// RuntimeInfo holds plugin runtime information.
	RuntimeInfo *RuntimeInfo
}

// RuntimeInfo represent plugin metrics and health check information captured at
// run time. Expect info here to change during plugin execution.
type RuntimeInfo struct {
	// statusMu mutex protects concurrent updates to plugin status.
	statusMu sync.RWMutex
	// status is the current plugin status.
	status acmpb.CurrentPluginStates_StatusValue
	// healthMu mutex protects plugin health check information.
	healthMu sync.Mutex
	// health is the current plugin health check information.
	health *healthCheck
	// metricsMu is a mutex that protects the metrics field.
	metricsMu sync.Mutex
	// metrics is a list of metrics reported by the plugin.
	metrics *boundedlist.List[Metric]
	// pidMu mutex protects plugin pid.
	pidMu sync.RWMutex
	// Pid is the process id of the plugin.
	Pid int
	// pendingStatusMu mutex protects pendingPluginStatus.
	pendingStatusMu sync.Mutex
	// pendingPluginStatus is the status of pending plugin revision. This is the
	// new revision that is being installed.
	pendingPluginStatus *pendingPluginStatus
}

// Manifest is the plugin specific static config agent received from ACP.
type Manifest struct {
	// StartAttempts is the number of times to try launching the plugin.
	StartAttempts int
	// MaxMemoryUsage is the maximum allowed memory usage of the plugin, in bytes.
	MaxMemoryUsage int64
	// MaxCPUUsage is the maximum allowed percent CPU usage of the plugin.
	MaxCPUUsage int32
	// MaxMetricDatapoints is the maximum number of datapoints to report/collect.
	// Metrics are collected every [MetricsInterval] but are flushed from memory
	// only when reported back to the service. This count limits datapoints from
	// growing indefinitely.
	MaxMetricDatapoints uint
	// MetricsInterval is the interval at which metrics are collected.
	MetricsInterval time.Duration
	// StopTimeout is the timeout set on plugin stop request before process is
	// killed.
	StopTimeout time.Duration
	// StartTimeout is the timeout set on plugin start request.
	StartTimeout time.Duration
	// StartConfig is the config service has sent down for passing down to the
	// plugin on each start RPC request.
	StartConfig *ServiceConfig
	// startConfigMu mutex protects concurrent updates to startConfig.
	startConfigMu sync.Mutex
	// startConfigHash is the hash of the start config. This is reported back to
	// the control plane to determine if there's a new config or not and applied
	// accordingly.
	startConfigHash string
	// LaunchArguments are extra arguments specified by plugin owners to pass down
	// during process launch.
	LaunchArguments []string
	// PluginType identifies if the plugin is a daemon or one-shot plugin.
	PluginType acmpb.PluginType
	// PluginInstallationType identifies if the plugin is installed locally or dynamically.
	PluginInstallationType acmpb.PluginInstallationType
	// ExecutionModel contains the execution model of the plugin.
	ExecutionModel *ExecutionModel
}

// ServiceConfig is agent agnostic data that is passed to the plugin on every
// start rpc request. At any given time only one of this can be set.
type ServiceConfig struct {
	// Simple is simple string form of the config.
	Simple string
	// Structured is structured [*structpb.Struct] config message. It is marshaled
	// to a byte array to persist across agent restarts and reuse on every plugin
	// start request. Agent will unmarshal using [toProto] method before sending
	// it to plugins on Start RPC.
	Structured []byte
}

// ExecutionModel is the execution model of the plugin.
type ExecutionModel struct {
	// DefaultState is the base state of the plugin before any rule evaluation.
	DefaultState acmpb.ExecutionModel_State
	// MatchState is the state of the plugin if the rule matches.
	MatchState acmpb.ExecutionModel_State
	// MatchPolicy is the policy to use when evaluating the rules.
	MatchPolicy acmpb.ExecutionModel_MatchPolicy
	// Rules is the list of rules to evaluate against the host.
	Rules []*Rule
	// BootCritical is true if the plugin must run immediately at boot after agent
	// is initialized.
	BootCritical bool
	// OneShot is the execution model for one-shot plugins.
	OneShot *OneShotExecutionModel
	// Daemon is the execution model for daemon plugins.
	Daemon *DaemonExecutionModel
}

// Rule is a rule for triggering a plugin.
type Rule struct {
	// The namespace is the namespace of the rule.
	Scope acmpb.Rules_Scope
	// Attribute is the attribute to compare against.
	Attribute string
	// ComparisonOperator to compare the attribute against the value.
	ComparisonOperator acmpb.Rules_ComparisonOperator
	// Value is the value to compare against. It is marshaled to a byte array to
	// persist across agent restarts and reuse on every plugin start request.
	// Agent will unmarshal using [toProto] method.
	Value []byte
}

// OneShotExecutionModel is the execution model for one-shot plugins.
type OneShotExecutionModel struct {
	// Triggers is the list of triggers to execute the plugin.
	Triggers *Triggers
	// ExecutionTimeout is the max duration plugin is allowed to run.
	ExecutionTimeout time.Duration
}

// Triggers is the list of triggers to execute the plugin.
type Triggers struct {
	// Startup is true if the plugin runs every time when the instance boots,
	// similar to startup script.
	Startup bool
	// Firstboot is true if the plugin runs only on the very first startup of an
	// instance, triggered by an agent detecting a new instance ID.
	Firstboot bool
	// Interval is a simple recurring schedule in minutes. Schedule begins at
	// instance startup.
	Interval time.Duration
}

// DaemonExecutionModel is the execution model for daemon plugins.
type DaemonExecutionModel struct{}

// toProto unmarshals bytes and returns struct proto message representation.
func (c *ServiceConfig) toProto() (*structpb.Struct, error) {
	cfg := &structpb.Struct{}
	if err := proto.Unmarshal(c.Structured, cfg); err != nil {
		return nil, fmt.Errorf("unable to unmarshal struct config bytes: %w", err)
	}
	return cfg, nil
}

func (p *Plugin) resetPendingStatus() {
	p.RuntimeInfo.pendingStatusMu.Lock()
	defer p.RuntimeInfo.pendingStatusMu.Unlock()
	p.RuntimeInfo.pendingPluginStatus = nil
}

func (p *Plugin) setPendingStatus(revision string, status acmpb.CurrentPluginStates_StatusValue) {
	p.RuntimeInfo.pendingStatusMu.Lock()
	defer p.RuntimeInfo.pendingStatusMu.Unlock()
	p.RuntimeInfo.pendingPluginStatus = &pendingPluginStatus{
		revision: revision,
		status:   status,
	}
}

func (p *Plugin) pendingStatus() *pendingPluginStatus {
	p.RuntimeInfo.pendingStatusMu.Lock()
	defer p.RuntimeInfo.pendingStatusMu.Unlock()
	return p.RuntimeInfo.pendingPluginStatus
}

// IsDaemon returns true if the plugin is a daemon plugin.
func (p *Plugin) IsDaemon() bool {
	return p.Manifest.PluginType == acmpb.PluginType_DAEMON
}

// IsOneShot returns true if the plugin is a one-shot plugin.
func (p *Plugin) IsOneShot() bool {
	return p.Manifest.PluginType == acmpb.PluginType_ONE_SHOT
}

// IsLocal returns true if the plugin is a local plugin.
func (p *Plugin) IsLocal() bool {
	return p.Manifest.PluginInstallationType == acmpb.PluginInstallationType_LOCAL_INSTALLATION
}

// IsDynamic returns true if the plugin is a dynamic plugin.
func (p *Plugin) IsDynamic() bool {
	return p.Manifest.PluginInstallationType == acmpb.PluginInstallationType_DYNAMIC_INSTALLATION
}

// pendingPluginStatus struct represents the pending plugin status. This is
// set only when a plugin revision is being changed.
type pendingPluginStatus struct {
	// revision is the pending plugin revision.
	revision string
	// status is the pending plugin status.
	status acmpb.CurrentPluginStates_StatusValue
}

// healthCheck struct represents the health check information.
type healthCheck struct {
	// responseCode is the response code returned by plugin during health check.
	responseCode int32
	// messages is the list of messages returned by plugin during health check.
	// This could include potential error reasons or any info plugins might want
	// to report to the service.
	messages []string
	// timestamp is the timestamp at which the health check was executed.
	timestamp time.Time
}

// healthInfo returns the current cached plugin health check information.
func (p *Plugin) healthInfo() *healthCheck {
	p.RuntimeInfo.healthMu.Lock()
	defer p.RuntimeInfo.healthMu.Unlock()
	return p.RuntimeInfo.health
}

// setHealthInfo sets the plugin health check information.
func (p *Plugin) setHealthInfo(h *healthCheck) {
	p.RuntimeInfo.healthMu.Lock()
	defer p.RuntimeInfo.healthMu.Unlock()
	p.RuntimeInfo.health = h
}

// FullName returns the full name of the plugin including name and revision.
func (p *Plugin) FullName() string {
	return fmt.Sprintf("%s_%s", p.Name, p.Revision)
}

// setPid sets the current plugin process id.
func (p *Plugin) setPid(pid int) {
	p.RuntimeInfo.pidMu.Lock()
	defer p.RuntimeInfo.pidMu.Unlock()
	p.RuntimeInfo.Pid = pid
}

// pid returns the current plugin process id.
func (p *Plugin) pid() int {
	p.RuntimeInfo.pidMu.RLock()
	defer p.RuntimeInfo.pidMu.RUnlock()
	return p.RuntimeInfo.Pid
}

// setState sets the plugin status.
func (p *Plugin) setState(s acmpb.CurrentPluginStates_StatusValue) {
	p.RuntimeInfo.statusMu.Lock()
	defer p.RuntimeInfo.statusMu.Unlock()
	p.RuntimeInfo.status = s
}

// State returns the plugin status.
func (p *Plugin) State() acmpb.CurrentPluginStates_StatusValue {
	p.RuntimeInfo.statusMu.RLock()
	defer p.RuntimeInfo.statusMu.RUnlock()
	return p.RuntimeInfo.status
}

// runSteps runs the steps in the order they are given.
func (p *Plugin) runSteps(ctx context.Context, steps []Step) error {
	for _, step := range steps {
		galog.Debugf("Running %q on plugin %q", step.Name(), p.FullName())
		p.setState(step.Status())
		if err := ctx.Err(); err != nil {
			p.setState(step.ErrorStatus())
			return fmt.Errorf("%q failed, context error: %w", step.Name(), err)
		}
		if err := step.Run(ctx, p); err != nil {
			p.setState(step.ErrorStatus())
			return fmt.Errorf("%q failed with error: %w", step.Name(), err)
		}
	}
	return nil
}

// PluginService returns the underlying plugin service client.
func (p *Plugin) PluginService() (pb.GuestAgentPluginClient, error) {
	p.clientMu.RLock()
	defer p.clientMu.RUnlock()
	if p.client == nil {
		return nil, fmt.Errorf("plugin %q is not connected", p.FullName())
	}
	return pb.NewGuestAgentPluginClient(p.client), nil
}

// buildStartRequest generates Start RPC request based on what service config
// was supplied during install.
func (p *Plugin) buildStartRequest(ctx context.Context) (*pb.StartRequest, error) {
	req := &pb.StartRequest{
		Config: &pb.StartRequest_Config{StateDirectoryPath: p.stateDir()},
	}

	if p.Manifest.StartConfig == nil {
		return req, nil
	}

	// Start config is optional and may not be present.
	if len(p.Manifest.StartConfig.Simple) != 0 {
		req.ServiceConfig = &pb.StartRequest_StringConfig{StringConfig: p.Manifest.StartConfig.Simple}
	} else if len(p.Manifest.StartConfig.Structured) != 0 {
		c, err := p.Manifest.StartConfig.toProto()
		if err != nil {
			return nil, fmt.Errorf("unable to generate start request for %q plugin: %w", p.FullName(), err)
		}
		req.ServiceConfig = &pb.StartRequest_StructConfig{StructConfig: c}
	}

	return req, nil
}

// Start makes plugin RPC start request.
func (p *Plugin) Start(ctx context.Context) (*pb.StartResponse, *status.Status) {
	galog.Debugf("Executing start request on plugin %q", p.FullName())

	svc, err := p.PluginService()
	if err != nil {
		return nil, status.Convert(fmt.Errorf("cannot call start on plugin %q: %w", p.FullName(), err))
	}

	policy := retry.Policy{MaxAttempts: p.Manifest.StartAttempts, BackoffFactor: 1, Jitter: time.Second}
	req, err := p.buildStartRequest(ctx)
	if err != nil {
		return nil, status.Convert(err)
	}

	tCtx, cancel := context.WithTimeout(ctx, p.Manifest.StartTimeout)
	defer cancel()

	f := func() (*pb.StartResponse, error) {
		return svc.Start(tCtx, req, grpc.WaitForReady(true))
	}

	resp, err := retry.RunWithResponse(tCtx, policy, f)
	return resp, status.Convert(err)
}

// Stop makes plugin RPC stop request.
func (p *Plugin) Stop(ctx context.Context, cleanup bool) (*pb.StopResponse, *status.Status) {
	galog.Debugf("Executing stop request on plugin %q", p.FullName())

	svc, err := p.PluginService()
	if err != nil {
		return nil, status.Convert(fmt.Errorf("cannot call stop on plugin %q: %w", p.FullName(), err))
	}

	req := &pb.StopRequest{
		Cleanup:  cleanup,
		Deadline: &dpb.Duration{Seconds: int64(p.Manifest.StopTimeout.Seconds())},
	}
	tCtx, cancel := context.WithTimeout(ctx, p.Manifest.StopTimeout)
	defer cancel()

	resp, err := svc.Stop(tCtx, req, grpc.WaitForReady(true))
	return resp, status.Convert(err)
}

// Apply makes plugin RPC apply request. Function accepts a service config
// which is passed down to the plugin with the apply request instead of using
// the one from the plugin manifest as it allows sending adhoc configs that
// can be used for one off operations without having to update the plugin.
// For example, this can be used to trigger VM event on plugins that support it.
func (p *Plugin) Apply(ctx context.Context, serviceConfig *ServiceConfig) (*pb.ApplyResponse, *status.Status) {
	galog.Debugf("Executing apply request on plugin %q", p.FullName())

	req, err := p.buildApplyRequest(serviceConfig)
	if err != nil {
		return nil, status.Convert(err)
	}

	svc, err := p.PluginService()
	if err != nil {
		return nil, status.Convert(fmt.Errorf("cannot call apply on plugin %q: %w", p.FullName(), err))
	}

	tCtx, cancel := context.WithTimeout(ctx, defaultApplyRPCTimeout)
	defer cancel()

	resp, err := svc.Apply(tCtx, req, grpc.WaitForReady(true))
	return resp, status.Convert(err)
}

// buildApplyRequest generates Apply RPC request based on what service config
// was supplied.
func (p *Plugin) buildApplyRequest(serviceConfig *ServiceConfig) (*pb.ApplyRequest, error) {
	req := &pb.ApplyRequest{}

	if serviceConfig == nil {
		return req, nil
	}

	// Start config is optional and may not be present.
	if len(serviceConfig.Simple) != 0 {
		req.ServiceConfig = &pb.ApplyRequest_StringConfig{StringConfig: serviceConfig.Simple}
	} else if len(serviceConfig.Structured) != 0 {
		c, err := serviceConfig.toProto()
		if err != nil {
			return nil, fmt.Errorf("unable to generate apply request for %q plugin: %w", p.FullName(), err)
		}
		req.ServiceConfig = &pb.ApplyRequest_StructConfig{StructConfig: c}
	}

	return req, nil
}

// GetStatus makes the GetStatus RPC request, [req] provides the
// context on what the request is about. For e.g. if we want status for task A,
// context could be task ID. For regular health check leave it empty.
func (p *Plugin) GetStatus(ctx context.Context, req string) (*pb.Status, *status.Status) {
	galog.Debugf("Executing get status request (%s) on plugin %q", req, p.FullName())

	var data *string
	tCtx, cancel := context.WithTimeout(ctx, defaultStatusRPCTimeout)
	defer cancel()

	if req != "" {
		data = proto.String(req)
	}

	svc, err := p.PluginService()
	if err != nil {
		return nil, status.Convert(fmt.Errorf("cannot call get status on plugin %q: %w", p.FullName(), err))
	}

	r := &pb.GetStatusRequest{Data: data}
	resp, err := svc.GetStatus(tCtx, r, grpc.WaitForReady(true))
	return resp, status.Convert(err)
}

// connectAddress returns the address to connect to based on protocol.
// Refer https://github.com/grpc/grpc/blob/master/doc/naming.md for address
// naming convention.
func (p *Plugin) connectAddress() string {
	if p.Protocol == tcpProtocol {
		return p.Address
	}
	return fmt.Sprintf("%s:%s", udsProtocol, p.Address)
}

// Connect tries to establish grpc connection to the plugin server.
func (p *Plugin) Connect(ctx context.Context) error {
	galog.Debugf("Dialing in on plugin %q", p.FullName())

	p.clientMu.Lock()
	defer p.clientMu.Unlock()

	if p.client != nil {
		// Close the previous client connection before attempting to reconnect.
		p.client.Close()
		p.client = nil
	}

	options := []grpc.DialOption{
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	}

	conn, err := grpc.NewClient(p.connectAddress(), options...)
	if err != nil {
		return fmt.Errorf("failed to dial on %q: %w", p.Address, err)
	}
	p.client = conn
	return nil
}

// stateFile returns the path to the state file for this plugin.
func (p *Plugin) stateFile() string {
	return filepath.Join(agentPluginState(), p.Name+".gob")
}

// Store writes plugin information to the file.
func (p *Plugin) Store() error {
	fname := p.stateFile()

	if err := os.MkdirAll(filepath.Dir(fname), 0755); err != nil {
		return fmt.Errorf("unable to create directory %q: %w", filepath.Dir(fname), err)
	}

	b := new(bytes.Buffer)
	err := gob.NewEncoder(b).Encode(p)
	if err != nil {
		return fmt.Errorf("unable to encode plugin: %w", err)
	}

	fh, err := os.OpenFile(fname, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err != nil {
		return fmt.Errorf("open file %q: %w", fname, err)
	}
	defer fh.Close()

	if _, err := fh.Write(b.Bytes()); err != nil {
		return fmt.Errorf("write plugin info to %q: %w", fname, err)
	}

	galog.V(2).Debugf("Sucessfully wrote plugin info to %q", fname)

	return nil
}

// IsRunning checks if the plugin is running by reconnecting and executing a
// health check.
func (p *Plugin) IsRunning(ctx context.Context) bool {
	galog.Debugf("Checking if plugin %q is running", p.FullName())
	if err := p.Connect(ctx); err != nil {
		galog.Debugf("Failed to connect to plugin %q: %v", p.FullName(), err)
		return false
	}

	_, e := p.GetStatus(ctx, "")
	if e.Err() != nil {
		// Health check failed, plugin is probably not running.
		galog.Debugf("Plugin health check failed, unable to get status of plugin %q: %+v", p.FullName(), e)
		return false
	}

	return true
}

// stateDir returns the path to the scratch directory for this plugin. This
// is the directory where the plugin can store any state that it needs to
// persist across revisions.
func (p *Plugin) stateDir() string {
	return filepath.Join(baseState(), agentStateDir, pluginInstallDir, p.Name)
}

// logfile returns the path to the log file for this plugin. These logs are
// written by the plugin which agent collects and pushes it out to the ACS when
// any plugin crash is detected.
// This log file not meant for general logging, but only for error logs plugins
// would want agent to collect and send to ACS. After every flush this log file
// is truncated.
func (p *Plugin) logfile() string {
	return filepath.Join(p.stateDir(), "plugin.log")
}

// staticInstallPath returns the install path which remains the same across
// revisions. This will eventually become a symlink to latest running plugin
// revision.
func (p *Plugin) staticInstallPath() string {
	return filepath.Join(baseState(), pluginInstallDir, p.Name)
}

// configHash returns the sha256 hash of the config applied to the plugin during
// the last start. If the config is nil it will return an empty string. If the
// hash is already computed, it will return the cached hash.
func (p *Plugin) configHash() string {
	p.Manifest.startConfigMu.Lock()
	defer p.Manifest.startConfigMu.Unlock()

	if p.Manifest.startConfigHash != "" || p.Manifest.StartConfig == nil {
		galog.V(2).Debugf("Returning cached start config hash or start config is nil for plugin %q, hash: %q", p.FullName(), p.Manifest.startConfigHash)
		return p.Manifest.startConfigHash
	}

	var data []byte
	// Either simple or structured config is expected to be present. This is
	// enforced by [Manifest.Config] proto message.
	if len(p.Manifest.StartConfig.Simple) != 0 {
		data = []byte(p.Manifest.StartConfig.Simple)
		galog.Debugf("Computing start config hash from string config for plugin %q", p.FullName())
	} else if len(p.Manifest.StartConfig.Structured) != 0 {
		data = p.Manifest.StartConfig.Structured
		galog.Debugf("Computing start config hash from structured config for plugin %q", p.FullName())
	} else {
		galog.Debugf("Start config is empty for plugin %q, skipping hash computation", p.FullName())
		return ""
	}

	hash := sha256.Sum256(data)
	p.Manifest.startConfigHash = hex.EncodeToString(hash[:])
	galog.Debugf("Updated start config hash for plugin %q", p.FullName())
	return p.Manifest.startConfigHash
}
