/*
Copyright The Helm Authors.
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/opencontainers/go-digest"
	ocispec "github.com/opencontainers/image-spec/specs-go/v1"

	"helm.sh/helm/v4/internal/test/ensure"
	"helm.sh/helm/v4/pkg/cli"
	"helm.sh/helm/v4/pkg/getter"
	"helm.sh/helm/v4/pkg/helmpath"
)

var _ Installer = new(OCIInstaller)

// createTestPluginTarGz creates a test plugin tar.gz with plugin.yaml
func createTestPluginTarGz(t *testing.T, pluginName string) []byte {
	t.Helper()

	var buf bytes.Buffer
	gzWriter := gzip.NewWriter(&buf)
	tarWriter := tar.NewWriter(gzWriter)

	// Add plugin.yaml
	pluginYAML := fmt.Sprintf(`name: %s
version: "1.0.0"
description: "Test plugin for OCI installer"
command: "$HELM_PLUGIN_DIR/bin/%s"
`, pluginName, pluginName)
	header := &tar.Header{
		Name:     "plugin.yaml",
		Mode:     0644,
		Size:     int64(len(pluginYAML)),
		Typeflag: tar.TypeReg,
	}
	if err := tarWriter.WriteHeader(header); err != nil {
		t.Fatal(err)
	}
	if _, err := tarWriter.Write([]byte(pluginYAML)); err != nil {
		t.Fatal(err)
	}

	// Add bin directory
	dirHeader := &tar.Header{
		Name:     "bin/",
		Mode:     0755,
		Typeflag: tar.TypeDir,
	}
	if err := tarWriter.WriteHeader(dirHeader); err != nil {
		t.Fatal(err)
	}

	// Add executable
	execContent := fmt.Sprintf("#!/bin/sh\necho '%s test plugin'", pluginName)
	execHeader := &tar.Header{
		Name:     "bin/" + pluginName,
		Mode:     0755,
		Size:     int64(len(execContent)),
		Typeflag: tar.TypeReg,
	}
	if err := tarWriter.WriteHeader(execHeader); err != nil {
		t.Fatal(err)
	}
	if _, err := tarWriter.Write([]byte(execContent)); err != nil {
		t.Fatal(err)
	}

	tarWriter.Close()
	gzWriter.Close()

	return buf.Bytes()
}

// mockOCIRegistryWithArtifactType creates a mock OCI registry server using the new artifact type approach
func mockOCIRegistryWithArtifactType(t *testing.T, pluginName string) (*httptest.Server, string) {
	t.Helper()

	pluginData := createTestPluginTarGz(t, pluginName)
	layerDigest := fmt.Sprintf("sha256:%x", sha256Sum(pluginData))

	// Create empty config data (as per OCI v1.1+ spec)
	configData := []byte("{}")
	configDigest := fmt.Sprintf("sha256:%x", sha256Sum(configData))

	// Create manifest with artifact type
	manifest := ocispec.Manifest{
		MediaType:    ocispec.MediaTypeImageManifest,
		ArtifactType: "application/vnd.helm.plugin.v1+json", // Using artifact type
		Config: ocispec.Descriptor{
			MediaType: "application/vnd.oci.empty.v1+json", // Empty config
			Digest:    digest.Digest(configDigest),
			Size:      int64(len(configData)),
		},
		Layers: []ocispec.Descriptor{
			{
				MediaType: "application/vnd.oci.image.layer.v1.tar",
				Digest:    digest.Digest(layerDigest),
				Size:      int64(len(pluginData)),
				Annotations: map[string]string{
					ocispec.AnnotationTitle: pluginName + "-1.0.0.tgz", // Layer named with version
				},
			},
		},
	}

	manifestData, err := json.Marshal(manifest)
	if err != nil {
		t.Fatal(err)
	}
	manifestDigest := fmt.Sprintf("sha256:%x", sha256Sum(manifestData))

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/") && !strings.Contains(r.URL.Path, "/manifests/") && !strings.Contains(r.URL.Path, "/blobs/"):
			// API version check
			w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			w.Write([]byte("{}"))

		case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/manifests/") && strings.Contains(r.URL.Path, pluginName):
			// Return manifest
			w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest)
			w.Header().Set("Docker-Content-Digest", manifestDigest)
			w.WriteHeader(http.StatusOK)
			w.Write(manifestData)

		case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+layerDigest):
			// Return layer data
			w.Header().Set("Content-Type", "application/vnd.oci.image.layer.v1.tar")
			w.WriteHeader(http.StatusOK)
			w.Write(pluginData)

		case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+configDigest):
			// Return config data
			w.Header().Set("Content-Type", "application/vnd.oci.empty.v1+json")
			w.WriteHeader(http.StatusOK)
			w.Write(configData)

		default:
			w.WriteHeader(http.StatusNotFound)
		}
	}))

	// Parse server URL to get host:port format for OCI reference
	serverURL, err := url.Parse(server.URL)
	if err != nil {
		t.Fatal(err)
	}
	registryHost := serverURL.Host

	return server, registryHost
}

// sha256Sum calculates SHA256 sum of data
func sha256Sum(data []byte) []byte {
	h := sha256.New()
	h.Write(data)
	return h.Sum(nil)
}

func TestNewOCIInstaller(t *testing.T) {
	tests := []struct {
		name        string
		source      string
		expectName  string
		expectError bool
	}{
		{
			name:        "valid OCI reference with tag",
			source:      "oci://ghcr.io/user/plugin-name:v1.0.0",
			expectName:  "plugin-name",
			expectError: false,
		},
		{
			name:        "valid OCI reference with digest",
			source:      "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef",
			expectName:  "plugin-name",
			expectError: false,
		},
		{
			name:        "valid OCI reference without tag",
			source:      "oci://ghcr.io/user/plugin-name",
			expectName:  "plugin-name",
			expectError: false,
		},
		{
			name:        "valid OCI reference with multiple path segments",
			source:      "oci://registry.example.com/org/team/plugin-name:latest",
			expectName:  "plugin-name",
			expectError: false,
		},
		{
			name:        "invalid OCI reference - no path",
			source:      "oci://registry.example.com",
			expectName:  "",
			expectError: true,
		},
		{
			name:        "valid OCI reference - single path segment",
			source:      "oci://registry.example.com/plugin",
			expectName:  "plugin",
			expectError: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			installer, err := NewOCIInstaller(tt.source)

			if tt.expectError {
				if err == nil {
					t.Error("expected error but got none")
				}
				return
			}

			if err != nil {
				t.Errorf("unexpected error: %v", err)
				return
			}

			// Check all fields thoroughly
			if installer.PluginName != tt.expectName {
				t.Errorf("expected plugin name %s, got %s", tt.expectName, installer.PluginName)
			}

			if installer.Source != tt.source {
				t.Errorf("expected source %s, got %s", tt.source, installer.Source)
			}

			if installer.CacheDir == "" {
				t.Error("expected non-empty cache directory")
			}

			if !strings.Contains(installer.CacheDir, "plugins") {
				t.Errorf("expected cache directory to contain 'plugins', got %s", installer.CacheDir)
			}

			if installer.settings == nil {
				t.Error("expected settings to be initialized")
			}

			// Check that Path() method works
			expectedPath := helmpath.DataPath("plugins", tt.expectName)
			if installer.Path() != expectedPath {
				t.Errorf("expected path %s, got %s", expectedPath, installer.Path())
			}
		})
	}
}

func TestOCIInstaller_Path(t *testing.T) {
	tests := []struct {
		name       string
		source     string
		pluginName string
		expectPath string
	}{
		{
			name:       "valid plugin name",
			source:     "oci://ghcr.io/user/plugin-name:v1.0.0",
			pluginName: "plugin-name",
			expectPath: helmpath.DataPath("plugins", "plugin-name"),
		},
		{
			name:       "empty source",
			source:     "",
			pluginName: "",
			expectPath: "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			installer := &OCIInstaller{
				PluginName: tt.pluginName,
				base:       newBase(tt.source),
				settings:   cli.New(),
			}

			path := installer.Path()
			if path != tt.expectPath {
				t.Errorf("expected path %s, got %s", tt.expectPath, path)
			}
		})
	}
}

func TestOCIInstaller_Install(t *testing.T) {
	// Set up isolated test environment
	ensure.HelmHome(t)

	pluginName := "test-plugin-basic"
	server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
	defer server.Close()

	// Test OCI reference
	source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)

	// Test with plain HTTP (since test server uses HTTP)
	installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
	if err != nil {
		t.Fatalf("Expected no error, got %v", err)
	}

	// The OCI installer uses helmpath.DataPath, which is isolated by ensure.HelmHome(t)
	actualPath := installer.Path()
	t.Logf("Installer will use path: %s", actualPath)

	// Install the plugin
	if err := Install(installer); err != nil {
		t.Fatalf("Expected installation to succeed, got error: %v", err)
	}

	// Verify plugin was installed to the correct location
	if !isPlugin(actualPath) {
		t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
	}

	// Debug: list what was actually created
	if entries, err := os.ReadDir(actualPath); err != nil {
		t.Fatalf("Could not read plugin directory %s: %v", actualPath, err)
	} else {
		t.Logf("Plugin directory %s contains:", actualPath)
		for _, entry := range entries {
			t.Logf("  - %s", entry.Name())
		}
	}

	// Verify the plugin.yaml file exists and is valid
	pluginFile := filepath.Join(actualPath, "plugin.yaml")
	if _, err := os.Stat(pluginFile); err != nil {
		t.Errorf("Expected plugin.yaml to exist, got error: %v", err)
	}
}

func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) {
	testCases := []struct {
		name       string
		pluginName string
		options    []getter.Option
		wantErr    bool
	}{
		{
			name:       "plain HTTP",
			pluginName: "example-cli-plain-http",
			options:    []getter.Option{getter.WithPlainHTTP(true)},
			wantErr:    false,
		},
		{
			name:       "insecure skip TLS verify",
			pluginName: "example-cli-insecure",
			options:    []getter.Option{getter.WithPlainHTTP(true), getter.WithInsecureSkipVerifyTLS(true)},
			wantErr:    false,
		},
		{
			name:       "with timeout",
			pluginName: "example-cli-timeout",
			options:    []getter.Option{getter.WithPlainHTTP(true), getter.WithTimeout(30 * time.Second)},
			wantErr:    false,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Set up isolated test environment for each subtest
			ensure.HelmHome(t)

			server, registryHost := mockOCIRegistryWithArtifactType(t, tc.pluginName)
			defer server.Close()

			source := fmt.Sprintf("oci://%s/%s:latest", registryHost, tc.pluginName)

			installer, err := NewOCIInstaller(source, tc.options...)
			if err != nil {
				if !tc.wantErr {
					t.Fatalf("Expected no error creating installer, got %v", err)
				}
				return
			}

			// The installer now uses our isolated test directory
			actualPath := installer.Path()

			// Install the plugin
			err = Install(installer)
			if tc.wantErr {
				if err == nil {
					t.Error("Expected installation to fail, but it succeeded")
				}
			} else {
				if err != nil {
					t.Errorf("Expected installation to succeed, got error: %v", err)
				} else {
					// Verify plugin was installed to the actual path
					if !isPlugin(actualPath) {
						t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
					}
				}
			}
		})
	}
}

func TestOCIInstaller_Install_AlreadyExists(t *testing.T) {
	// Set up isolated test environment
	ensure.HelmHome(t)

	pluginName := "test-plugin-exists"
	server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
	defer server.Close()

	source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
	installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
	if err != nil {
		t.Fatalf("Expected no error, got %v", err)
	}

	// First install should succeed
	if err := Install(installer); err != nil {
		t.Fatalf("Expected first installation to succeed, got error: %v", err)
	}

	// Verify plugin was installed
	if !isPlugin(installer.Path()) {
		t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
	}

	// Second install should fail with "plugin already exists"
	err = Install(installer)
	if err == nil {
		t.Error("Expected error when installing plugin that already exists")
	} else if !strings.Contains(err.Error(), "plugin already exists") {
		t.Errorf("Expected 'plugin already exists' error, got: %v", err)
	}
}

func TestOCIInstaller_Update(t *testing.T) {
	// Set up isolated test environment
	ensure.HelmHome(t)

	pluginName := "test-plugin-update"
	server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
	defer server.Close()

	source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
	installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
	if err != nil {
		t.Fatalf("Expected no error, got %v", err)
	}

	// Test update when plugin does not exist - should fail
	err = Update(installer)
	if err == nil {
		t.Error("Expected error when updating plugin that does not exist")
	} else if !strings.Contains(err.Error(), "plugin does not exist") {
		t.Errorf("Expected 'plugin does not exist' error, got: %v", err)
	}

	// Install plugin first
	if err := Install(installer); err != nil {
		t.Fatalf("Expected installation to succeed, got error: %v", err)
	}

	// Verify plugin was installed
	if !isPlugin(installer.Path()) {
		t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
	}

	// Test update when plugin exists - should succeed
	// For OCI, Update() removes old version and reinstalls
	if err := Update(installer); err != nil {
		t.Errorf("Expected update to succeed, got error: %v", err)
	}

	// Verify plugin is still installed after update
	if !isPlugin(installer.Path()) {
		t.Errorf("Expected plugin directory %s to contain plugin.yaml after update", installer.Path())
	}
}

func TestOCIInstaller_Install_ComponentExtraction(t *testing.T) {
	// Test that we can extract a plugin archive properly
	// This tests the extraction logic that Install() uses
	tempDir := t.TempDir()
	pluginName := "test-plugin-extract"

	pluginData := createTestPluginTarGz(t, pluginName)

	// Test extraction
	err := extractTarGz(bytes.NewReader(pluginData), tempDir)
	if err != nil {
		t.Fatalf("Failed to extract plugin: %v", err)
	}

	// Verify plugin.yaml exists
	pluginYAMLPath := filepath.Join(tempDir, "plugin.yaml")
	if _, err := os.Stat(pluginYAMLPath); os.IsNotExist(err) {
		t.Error("plugin.yaml not found after extraction")
	}

	// Verify bin directory exists
	binPath := filepath.Join(tempDir, "bin")
	if _, err := os.Stat(binPath); os.IsNotExist(err) {
		t.Error("bin directory not found after extraction")
	}

	// Verify executable exists and has correct permissions
	execPath := filepath.Join(tempDir, "bin", pluginName)
	if info, err := os.Stat(execPath); err != nil {
		t.Errorf("executable not found: %v", err)
	} else if info.Mode()&0111 == 0 {
		t.Error("file is not executable")
	}

	// Verify this would be recognized as a plugin
	if !isPlugin(tempDir) {
		t.Error("extracted directory is not a valid plugin")
	}
}

func TestExtractTarGz(t *testing.T) {
	tempDir := t.TempDir()

	// Create a test tar.gz file
	var buf bytes.Buffer
	gzWriter := gzip.NewWriter(&buf)
	tarWriter := tar.NewWriter(gzWriter)

	// Add a test file to the archive
	testContent := "test content"
	header := &tar.Header{
		Name:     "test-file.txt",
		Mode:     0644,
		Size:     int64(len(testContent)),
		Typeflag: tar.TypeReg,
	}

	if err := tarWriter.WriteHeader(header); err != nil {
		t.Fatal(err)
	}

	if _, err := tarWriter.Write([]byte(testContent)); err != nil {
		t.Fatal(err)
	}

	// Add a test directory
	dirHeader := &tar.Header{
		Name:     "test-dir/",
		Mode:     0755,
		Typeflag: tar.TypeDir,
	}

	if err := tarWriter.WriteHeader(dirHeader); err != nil {
		t.Fatal(err)
	}

	tarWriter.Close()
	gzWriter.Close()

	// Test extraction
	err := extractTarGz(bytes.NewReader(buf.Bytes()), tempDir)
	if err != nil {
		t.Errorf("extractTarGz failed: %v", err)
	}

	// Verify extracted file
	extractedFile := filepath.Join(tempDir, "test-file.txt")
	content, err := os.ReadFile(extractedFile)
	if err != nil {
		t.Errorf("failed to read extracted file: %v", err)
	}

	if string(content) != testContent {
		t.Errorf("expected content %s, got %s", testContent, string(content))
	}

	// Verify extracted directory
	extractedDir := filepath.Join(tempDir, "test-dir")
	if _, err := os.Stat(extractedDir); os.IsNotExist(err) {
		t.Errorf("extracted directory does not exist: %s", extractedDir)
	}
}

func TestExtractTarGz_InvalidGzip(t *testing.T) {
	tempDir := t.TempDir()

	// Test with invalid gzip data
	invalidGzipData := []byte("not gzip data")
	err := extractTarGz(bytes.NewReader(invalidGzipData), tempDir)
	if err == nil {
		t.Error("expected error for invalid gzip data")
	}
}

func TestExtractTar_UnknownFileType(t *testing.T) {
	tempDir := t.TempDir()

	// Create a test tar file
	var buf bytes.Buffer
	tarWriter := tar.NewWriter(&buf)

	// Add a test file
	testContent := "test content"
	header := &tar.Header{
		Name:     "test-file.txt",
		Mode:     0644,
		Size:     int64(len(testContent)),
		Typeflag: tar.TypeReg,
	}

	if err := tarWriter.WriteHeader(header); err != nil {
		t.Fatal(err)
	}

	if _, err := tarWriter.Write([]byte(testContent)); err != nil {
		t.Fatal(err)
	}

	// Test unknown file type
	unknownHeader := &tar.Header{
		Name:     "unknown-type",
		Mode:     0644,
		Typeflag: tar.TypeSymlink, // Use a type that's not handled
	}

	if err := tarWriter.WriteHeader(unknownHeader); err != nil {
		t.Fatal(err)
	}

	tarWriter.Close()

	// Test extraction - should fail due to unknown type
	err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
	if err == nil {
		t.Error("expected error for unknown tar file type")
	}

	if !strings.Contains(err.Error(), "unknown type") {
		t.Errorf("expected 'unknown type' error, got: %v", err)
	}
}

func TestExtractTar_SuccessfulExtraction(t *testing.T) {
	tempDir := t.TempDir()

	// Since we can't easily create extended headers with Go's tar package,
	// we'll test the logic that skips them by creating a simple tar with regular files
	// and then testing that the extraction works correctly.

	// Create a test tar file
	var buf bytes.Buffer
	tarWriter := tar.NewWriter(&buf)

	// Add a regular file
	testContent := "test content"
	header := &tar.Header{
		Name:     "test-file.txt",
		Mode:     0644,
		Size:     int64(len(testContent)),
		Typeflag: tar.TypeReg,
	}

	if err := tarWriter.WriteHeader(header); err != nil {
		t.Fatal(err)
	}

	if _, err := tarWriter.Write([]byte(testContent)); err != nil {
		t.Fatal(err)
	}

	tarWriter.Close()

	// Test extraction
	err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
	if err != nil {
		t.Errorf("extractTar failed: %v", err)
	}

	// Verify the regular file was extracted
	extractedFile := filepath.Join(tempDir, "test-file.txt")
	content, err := os.ReadFile(extractedFile)
	if err != nil {
		t.Errorf("failed to read extracted file: %v", err)
	}

	if string(content) != testContent {
		t.Errorf("expected content %s, got %s", testContent, string(content))
	}
}

func TestOCIInstaller_Install_PlainHTTPOption(t *testing.T) {
	// Test that PlainHTTP option is properly passed to getter
	source := "oci://example.com/test-plugin:v1.0.0"

	// Test with PlainHTTP=false (default)
	installer1, err := NewOCIInstaller(source)
	if err != nil {
		t.Fatalf("failed to create installer: %v", err)
	}
	if installer1.getter == nil {
		t.Error("getter should be initialized")
	}

	// Test with PlainHTTP=true
	installer2, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
	if err != nil {
		t.Fatalf("failed to create installer with PlainHTTP=true: %v", err)
	}
	if installer2.getter == nil {
		t.Error("getter should be initialized with PlainHTTP=true")
	}

	// Both installers should have the same basic properties
	if installer1.PluginName != installer2.PluginName {
		t.Error("plugin names should match")
	}
	if installer1.Source != installer2.Source {
		t.Error("sources should match")
	}

	// Test with multiple options
	installer3, err := NewOCIInstaller(source,
		getter.WithPlainHTTP(true),
		getter.WithBasicAuth("user", "pass"),
	)
	if err != nil {
		t.Fatalf("failed to create installer with multiple options: %v", err)
	}
	if installer3.getter == nil {
		t.Error("getter should be initialized with multiple options")
	}
}

func TestOCIInstaller_Install_ValidationErrors(t *testing.T) {
	tests := []struct {
		name        string
		layerData   []byte
		expectError bool
		errorMsg    string
	}{
		{
			name:        "non-gzip layer",
			layerData:   []byte("not gzip data"),
			expectError: true,
			errorMsg:    "is not a gzip compressed archive",
		},
		{
			name:        "empty layer",
			layerData:   []byte{},
			expectError: true,
			errorMsg:    "is not a gzip compressed archive",
		},
		{
			name:        "single byte layer",
			layerData:   []byte{0x1f},
			expectError: true,
			errorMsg:    "is not a gzip compressed archive",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Test the gzip validation logic that's used in the Install method
			if len(tt.layerData) < 2 || tt.layerData[0] != 0x1f || tt.layerData[1] != 0x8b {
				// This matches the validation in the Install method
				if !tt.expectError {
					t.Error("expected valid gzip data")
				}
				if !strings.Contains(tt.errorMsg, "is not a gzip compressed archive") {
					t.Error("expected error message to contain 'is not a gzip compressed archive'")
				}
			}
		})
	}
}
