//  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 (
	"archive/tar"
	"compress/gzip"
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
	"testing"
	"time"

	acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp"
	"github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg"
	"github.com/google/go-cmp/cmp"
	dpb "google.golang.org/protobuf/types/known/durationpb"
)

func TestDownloadStep(t *testing.T) {
	content := "test"
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, content)
	}))
	defer ts.Close()

	downloadPath := filepath.Join(t.TempDir(), "download")

	step := &downloadStep{
		url:        ts.URL + "/download",
		targetPath: downloadPath,
		// This is SHA256 of "test" string.
		checksum: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
		attempts: 1,
	}

	wantStatus := acmpb.CurrentPluginStates_INSTALLING
	if got := step.Status(); got != wantStatus {
		t.Errorf("downloadStep.Status() = %q, want %q", got, wantStatus)
	}

	wantErrorStatus := acmpb.CurrentPluginStates_INSTALL_FAILED
	if got := step.ErrorStatus(); got != wantErrorStatus {
		t.Errorf("downloadStep.ErrorStatus() = %s, want %s", got, wantErrorStatus)
	}

	wantName := "DownloadPluginStep"
	if got := step.Name(); got != wantName {
		t.Errorf("downloadStep.Name() = %q, want %q", got, wantName)
	}

	if err := step.Run(context.Background(), &Plugin{}); err != nil {
		t.Fatalf("step.Run(ctx) failed unexpectedly with error: %v", err)
	}

	got, err := os.ReadFile(downloadPath)
	if err != nil {
		t.Fatalf("os.ReadFile(%q) failed unexpectedly with error: %v", downloadPath, err)
	}
	if string(got) != content {
		t.Errorf("os.ReadFile(%q) = %q, want %q", downloadPath, string(got), content)
	}
}

func TestDownloadStepError(t *testing.T) {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.Contains(r.URL.Path, "error") {
			http.Error(w, "test url error", http.StatusInternalServerError)
		} else {
			fmt.Fprint(w, "test")
		}
	}))
	defer ts.Close()

	validSum := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
	downloadPath := filepath.Join(t.TempDir(), "download")

	tests := []struct {
		desc     string
		url      string
		path     string
		checksum string
	}{
		{
			desc:     "invalid_http_code",
			url:      ts.URL + "/error",
			path:     downloadPath,
			checksum: validSum,
		},
		{
			desc:     "invalid_checksum",
			url:      ts.URL + "/download",
			path:     downloadPath,
			checksum: "incorrect-cksum",
		},
	}

	for _, tc := range tests {
		t.Run(tc.desc, func(t *testing.T) {
			step := &downloadStep{
				targetPath: tc.path,
				attempts:   1,
				url:        tc.url,
				checksum:   tc.checksum,
			}

			if err := step.Run(context.Background(), &Plugin{}); err == nil {
				t.Errorf("step.Run(ctx) succeeded for malformed input, want error")
			}
		})
	}
}

type testStep struct {
	executedAt  time.Time
	throwError  error
	status      acmpb.CurrentPluginStates_StatusValue
	errorStatus acmpb.CurrentPluginStates_StatusValue
}

func (t *testStep) Run(context.Context, *Plugin) error {
	t.executedAt = time.Now()
	if t.throwError != nil {
		return t.throwError
	}
	return nil
}

func (t *testStep) Status() acmpb.CurrentPluginStates_StatusValue {
	return t.status
}

func (t *testStep) ErrorStatus() acmpb.CurrentPluginStates_StatusValue {
	return t.errorStatus
}

func (t *testStep) Name() string {
	return "testStep"
}

func setBaseStateDir(t *testing.T, addr string) {
	t.Helper()
	if err := cfg.Load([]byte{}); err != nil {
		t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err)
	}
	cfg.Retrieve().Plugin.StateDir = addr
}

func TestBaseState(t *testing.T) {
	if err := cfg.Load([]byte{}); err != nil {
		t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err)
	}

	want := map[string]string{
		"windows": `C:\ProgramData\Google\Compute Engine\google-guest-agent`,
		"linux":   "/var/lib/google-guest-agent",
	}[runtime.GOOS]

	got := baseState()
	if got != want {
		t.Errorf("baseState() = %q, want %q", got, want)
	}
}

func TestPluginState(t *testing.T) {
	if err := cfg.Load(nil); err != nil {
		t.Fatalf("Failed to load config: %v", err)
	}

	tests := []struct {
		desc     string
		base     string
		name     string
		revision string
		want     string
	}{
		{
			desc:     "plugin_state_pluginname",
			base:     "/some/path",
			name:     "pluginname",
			revision: "revision",
			want:     filepath.Join("/some/path", "plugins/pluginname_revision"),
		},
		{
			desc:     "plugin_state_pluginname2",
			base:     "/some/path2",
			name:     "pluginname2",
			revision: "revision2",
			want:     filepath.Join("/some/path2", "plugins/pluginname2_revision2"),
		},
	}

	for _, tc := range tests {
		t.Run(tc.desc, func(t *testing.T) {
			cfg.Retrieve().Plugin.StateDir = tc.base
			if got := pluginInstallPath(tc.name, tc.revision); got != tc.want {
				t.Errorf("pluginState(%s, %s) = %q, want %q", tc.base, tc.name, got, tc.want)
			}
		})
	}
}

func TestRunSteps(t *testing.T) {
	plugin := &Plugin{RuntimeInfo: &RuntimeInfo{statusMu: sync.RWMutex{}}}

	step1 := &testStep{status: acmpb.CurrentPluginStates_INSTALLING}
	wantState := acmpb.CurrentPluginStates_RUNNING
	step2 := &testStep{status: wantState}

	if err := plugin.runSteps(context.Background(), []Step{step1, step2}); err != nil {
		t.Fatalf("plugin.runSteps(ctx, %+v) failed unexpectedly with error: %v", step1, err)
	}

	if step1.executedAt.After(step2.executedAt) {
		t.Errorf("plugin.runSteps(ctx, steps) executed in wrong order, step1.executedAt = %v, want earlier than step2.executedAt = %v", step1.executedAt, step2.executedAt)
	}

	if got := plugin.State(); got != wantState {
		t.Errorf("plugin.Status() = %q, want %q", got, wantState)
	}
}

func TestRunStepsError(t *testing.T) {
	ctx := context.Background()
	plugin := &Plugin{
		Name:        "testPlugin",
		Revision:    "testPluginRevision",
		RuntimeInfo: &RuntimeInfo{statusMu: sync.RWMutex{}},
	}

	tests := []struct {
		desc          string
		steps         []Step
		wantError     error
		cancelContext bool
		wantState     acmpb.CurrentPluginStates_StatusValue
	}{
		{
			desc:      "fail_at_first_step",
			steps:     []Step{&testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_INSTALL_FAILED, throwError: context.DeadlineExceeded}},
			wantError: context.DeadlineExceeded,
			wantState: acmpb.CurrentPluginStates_INSTALL_FAILED,
		},
		{
			desc:      "fail_after_first_step",
			steps:     []Step{&testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_INSTALL_FAILED}, &testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_CRASHED, throwError: fmt.Errorf("test error")}},
			wantError: fmt.Errorf("test error"),
			wantState: acmpb.CurrentPluginStates_CRASHED,
		},
		{
			desc:          "fail_immediately",
			steps:         []Step{&testStep{status: acmpb.CurrentPluginStates_INSTALLING, errorStatus: acmpb.CurrentPluginStates_INSTALL_FAILED}},
			wantError:     context.Canceled,
			cancelContext: true,
			wantState:     acmpb.CurrentPluginStates_INSTALL_FAILED,
		},
	}

	for _, tc := range tests {
		t.Run(tc.desc, func(t *testing.T) {
			ctx, cancel := context.WithTimeout(ctx, time.Minute)
			if tc.cancelContext {
				cancel()
			} else {
				defer cancel()
			}

			err := plugin.runSteps(ctx, tc.steps)
			gotErr := errors.Unwrap(err)
			if gotErr.Error() != tc.wantError.Error() {
				t.Errorf("plugin.runSteps(ctx, %+v) = %v, want %v", tc.steps, err, tc.wantError)
			}
			if got := plugin.State(); got != tc.wantState {
				t.Errorf("plugin.Status() = %q, want %q", got, tc.wantState)
			}
		})
	}
}

func TestGenerateInstallWorkflow(t *testing.T) {
	pm := &PluginManager{protocol: udsProtocol}
	req := &acmpb.ConfigurePluginStates_ConfigurePlugin{
		Action: acmpb.ConfigurePluginStates_INSTALL,
		Plugin: &acmpb.ConfigurePluginStates_Plugin{
			Name:         "testPlugin",
			RevisionId:   "abc",
			GcsSignedUrl: "https://test.com",
			Checksum:     "testPluginChecksum",
			EntryPoint:   "testPluginEntryPoint",
		},
		Manifest: &acmpb.ConfigurePluginStates_Manifest{
			DownloadTimeout:      &dpb.Duration{Seconds: 60},
			DownloadAttemptCount: 1,
			MaxMemoryUsageBytes:  100,
			StartAttemptCount:    3,
		},
	}

	baseDir := t.TempDir()
	setBaseStateDir(t, baseDir)
	p := pluginInstallPath(req.GetPlugin().GetName(), req.GetPlugin().GetRevisionId())
	archivePath := filepath.Join(baseDir, req.GetPlugin().GetName()+".tar.gz")
	ctx := context.Background()

	tests := []struct {
		name             string
		installationType acmpb.PluginInstallationType
		wantSteps        []Step
	}{
		{
			name:             "dynamic-plugin",
			installationType: acmpb.PluginInstallationType_DYNAMIC_INSTALLATION,
			wantSteps: []Step{
				&downloadStep{url: "https://test.com", targetPath: archivePath, checksum: "testPluginChecksum", attempts: 1, timeout: 60 * time.Second},
				&unpackStep{archivePath: archivePath, targetDir: p},
				&launchStep{entryPath: filepath.Join(p, "testPluginEntryPoint"), maxMemoryUsage: 100, startAttempts: 3, protocol: pm.protocol},
			},
		},
		{
			name:             "local-plugin",
			installationType: acmpb.PluginInstallationType_LOCAL_INSTALLATION,
			wantSteps: []Step{
				&launchStep{entryPath: "testPluginEntryPoint", maxMemoryUsage: 100, startAttempts: 3, protocol: pm.protocol},
			},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			req.Manifest.PluginInstallationType = tc.installationType

			gotSteps := pm.generateInstallWorkflow(ctx, req)

			allow := []any{downloadStep{}, unpackStep{}, launchStep{}}
			if diff := cmp.Diff(tc.wantSteps, gotSteps, cmp.AllowUnexported(allow...)); diff != "" {
				t.Errorf("generateInstallWorkflow(ctx, %+v) returned diff (-want +got):\n%s", req, diff)
			}
		})
	}

	req.Action = acmpb.ConfigurePluginStates_REMOVE

	if gotSteps := pm.generateInstallWorkflow(ctx, req); gotSteps != nil {
		t.Errorf("generateInstallWorkflow(ctx, %+v) = %+v, want empty", req, gotSteps)
	}
}

func createTestArchive(t *testing.T) string {
	t.Helper()

	tempDir := t.TempDir()
	tempFile := filepath.Join(tempDir, "test.tar.gz")
	f, err := os.Create(tempFile)
	if err != nil {
		t.Fatalf("error creating test file: %v", err)
	}
	defer func() {
		if err := f.Close(); err != nil {
			t.Fatalf("error closing test archive file: %v", err)
		}
	}()

	gw := gzip.NewWriter(f)
	defer func() {
		if err := gw.Close(); err != nil {
			t.Fatalf("error closing test archive gzip writer: %v", err)
		}
	}()

	tw := tar.NewWriter(gw)
	defer func() {
		if err := tw.Close(); err != nil {
			t.Fatalf("error closing test archive tar writer: %v", err)
		}
	}()

	name := filepath.Join("dir1", "file1")
	body := "test content"

	if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0777, Size: int64(len(body))}); err != nil {
		t.Fatalf("error creating test archive header: %v", err)
	}

	if _, err := tw.Write([]byte(body)); err != nil {
		t.Fatalf("error writing test archive content: %v", err)
	}

	return tempFile
}

func TestUnpackStepRun(t *testing.T) {
	out := filepath.Join(t.TempDir(), "unpack-to")
	archive := createTestArchive(t)

	tests := []struct {
		desc    string
		step    *unpackStep
		wantErr bool
	}{
		{
			desc: "success",
			step: &unpackStep{archivePath: archive, targetDir: out},
		},
		{
			desc:    "failure",
			step:    &unpackStep{archivePath: "/non/existent/archive", targetDir: t.TempDir()},
			wantErr: true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.desc, func(t *testing.T) {
			step := tc.step
			wantStatus := acmpb.CurrentPluginStates_INSTALLING
			if got := step.Status(); got != wantStatus {
				t.Errorf("downloadStep.Status() = %q, want %q", got, wantStatus)
			}

			wantErrorStatus := acmpb.CurrentPluginStates_INSTALL_FAILED
			if got := step.ErrorStatus(); got != wantErrorStatus {
				t.Errorf("downloadStep.ErrorStatus() = %s, want %s", got, wantErrorStatus)
			}

			wantName := "UnpackPluginArchiveStep"
			if got := step.Name(); got != wantName {
				t.Errorf("downloadStep.Name() = %q, want %q", got, wantName)
			}

			if err := step.Run(context.Background(), &Plugin{}); (err != nil) != tc.wantErr {
				t.Errorf("step.Run(ctx): got error %v, want error %v", err, tc.wantErr)
			}

			if _, err := os.Stat(step.archivePath); !os.IsNotExist(err) {
				t.Errorf("os.Stat(%s) = %v, want %v", step.archivePath, err, os.ErrNotExist)
			}

			if _, err := os.Stat(step.targetDir); err != nil {
				t.Errorf("os.Stat(%s) failed unexpectedly with error: %v", step.targetDir, err)
			}
		})
	}
}

func TestRelaunchWorkflow(t *testing.T) {
	ctx := context.Background()
	p := &Plugin{
		Name:      "testPlugin",
		Revision:  "testPluginRevision",
		EntryPath: "testPluginEntryPoint",
		Manifest:  &Manifest{MaxMemoryUsage: 100, StartAttempts: 3},
		Protocol:  udsProtocol,
	}
	want := []Step{
		&stopStep{cleanup: false},
		&launchStep{entryPath: p.EntryPath, maxMemoryUsage: p.Manifest.MaxMemoryUsage, startAttempts: p.Manifest.StartAttempts, protocol: p.Protocol},
	}

	got := relaunchWorkflow(ctx, p)

	allow := []any{stopStep{}, launchStep{}}
	if diff := cmp.Diff(want, got, cmp.AllowUnexported(allow...)); diff != "" {
		t.Errorf("relaunchWorkflow(ctx, %+v) returned diff (-want +got):\n%s", p, diff)
	}
}
