//  Copyright 2025 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 (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/GoogleCloudPlatform/galog"
	acpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp"
	"github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file"
)

// retryFailedRemovals removes all old plugin states.
//
// This function is called by the cleanup watcher to remove old plugin states.
// This will primarily retry removing plugins that weren't properly removed before.
func (m *PluginManager) retryFailedRemovals(ctx context.Context) (bool, error) {
	galog.Debugf("Cleaning up old/failed plugin states")
	// Check for plugins that aren't cached by the guest agent that should be removed.
	pluginInstallLoc := filepath.Join(baseState(), pluginInstallDir)
	pluginDirs, err := os.ReadDir(pluginInstallLoc)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			galog.V(1).Debugf("Plugin install directory %q does not exist, nothing to clean up", pluginInstallLoc)
			return true, nil
		}
		return true, fmt.Errorf("failed to read plugin install directory: %w", err)
	}

	// Keep track of the links to the plugin directories. We need to verify that
	// the link points to a plugin directory that is no longer cached by the guest
	// agent before removing it.
	var links []string
	failedRemovals := make(map[string]*Plugin)
	m.pendingPluginRevisionsMu.RLock()
	for _, p := range pluginDirs {
		if !p.IsDir() {
			links = append(links, p.Name())
			continue
		}
		// Plugin directories have the form <name>_<revision>. The revision is
		// typically a hash, so it should be safe to split by the last underscore.
		nameIndex := strings.LastIndex(p.Name(), "_")
		pluginName := p.Name()[:nameIndex]
		pluginRevision := p.Name()[nameIndex+1:]
		galog.V(1).Debugf("Checking plugin %q, revision %q", pluginName, pluginRevision)
		// We want to avoid cleaning up plugins that currently have a request in progress.
		// This is to prevent a race condition where the plugin is removed in the
		// middle of an installation request.
		if m.inProgressPluginRequests[pluginName] {
			continue
		}
		if _, err := m.Fetch(p.Name()); err != nil {
			installPath := filepath.Join(pluginInstallLoc, p.Name())
			failedRemovals[p.Name()] = &Plugin{Name: pluginName, Revision: pluginRevision, InstallPath: installPath, Manifest: &Manifest{PluginInstallationType: acpb.PluginInstallationType_DYNAMIC_INSTALLATION}}
			galog.Debugf("Found leftover plugin %q, marking for removal", p.Name())
		}
	}
	m.pendingPluginRevisionsMu.RUnlock()

	// Remove the links to the plugin directories that are no longer cached by the guest agent.
	for _, link := range links {
		dest, err := os.Readlink(filepath.Join(pluginInstallLoc, link))
		if err != nil {
			continue
		}
		// Check if the link points to a plugin directory that is no longer cached
		// by the guest agent.
		galog.V(1).Debugf("Checking link %q", link)
		if _, ok := failedRemovals[filepath.Base(dest)]; ok {
			linkPath := filepath.Join(pluginInstallLoc, link)
			galog.V(1).Debugf("Removing link %q", linkPath)
			if err := os.Remove(linkPath); err != nil {
				galog.Debugf("Unable to remove link %q: %v", linkPath, err)
			}
		}
	}

	var errs []error
	noop := len(failedRemovals) == 0
	for _, p := range failedRemovals {
		galog.Debugf("Cleaning up plugin %q", p.FullName())
		if err := cleanup(ctx, p); err != nil {
			errs = append(errs, err)
		}
	}
	galog.Debugf("Finished cleaning up old/failed plugin states")
	return noop, errors.Join(errs...)
}

func (m *PluginManager) cleanupOldState(ctx context.Context, path string) error {
	re := regexp.MustCompile("^[0-9]+$")

	if !file.Exists(path, file.TypeDir) {
		// This is not an error, it just means there's nothing to clean up, which
		// can happen if the agent is started for the first time or plugins were
		// never installed.
		galog.Debugf("Plugin state directory %q does not exist, skipping cleanup", path)
		return nil
	}

	dirs, err := os.ReadDir(path)
	if err != nil {
		return fmt.Errorf("failed to read directory %q: %w", path, err)
	}

	currentID := m.currentInstanceID()

	for _, dir := range dirs {
		absPath := filepath.Join(path, dir.Name())
		// Skip the current instance directory and any non-numeric directories,
		// these are most likely not agent created.
		if !dir.IsDir() || dir.Name() == currentID || !re.MatchString(dir.Name()) {
			galog.V(2).Debugf("Skipping %q from plugin manager old state cleanup", absPath)
			continue
		}
		galog.Debugf("Removing previous plugin state %q", absPath)
		if err := os.RemoveAll(absPath); err != nil {
			return fmt.Errorf("failed to remove file %q: %w", absPath, err)
		}
	}
	return nil
}

// CleanupJob is a job that cleans up old plugin states.
type CleanupJob struct {
	pm *PluginManager
}

// newCleanupJob creates a new cleanup job.
func newCleanupJob(pm *PluginManager) *CleanupJob {
	return &CleanupJob{pm: pm}
}

// ID returns the ID of the cleanup job.
func (s *CleanupJob) ID() string {
	return "cleanup"
}

// MetricName returns the metric name of the cleanup job.
func (s *CleanupJob) MetricName() acpb.GuestAgentModuleMetric_Metric {
	return acpb.GuestAgentModuleMetric_PLUGIN_CLEANUP
}

// Interval returns the interval of the cleanup job. This is set to 24 hours
// to run once a day.
func (s *CleanupJob) Interval() (time.Duration, bool) {
	return 24 * time.Hour, true
}

// ShouldEnable returns true if the cleanup job should be enabled.
//
// This should always be enabled to ensure that old plugin states are cleaned
// up.
func (s *CleanupJob) ShouldEnable(context.Context) bool {
	return true
}

// Run runs the cleanup job.
func (s *CleanupJob) Run(ctx context.Context) (bool, error) {
	galog.Debugf("Running cleanup job")
	noop, err := s.pm.retryFailedRemovals(ctx)
	galog.Debugf("Finished running cleanup job")
	return noop, err
}
