package deployer

import (
	"context"
	"fmt"
	"testing"
	"time"

	networkingv1 "k8s.io/api/networking/v1"
	"k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/runtime/schema"

	"github.com/orchard9/rdev/internal/domain"
)

func TestBuildIngress_WithBasePath(t *testing.T) {
	d := &Deployer{config: Config{IngressClass: "traefik", TLSIssuer: "letsencrypt"}}
	pathType := networkingv1.PathTypePrefix

	tests := []struct {
		name         string
		spec         domain.DeploySpec
		expectedPath string
	}{
		{
			name: "uses BasePath when set",
			spec: domain.DeploySpec{
				ProjectName: "myapp",
				Domain:      "myapp.threesix.ai",
				Port:        8080,
				BasePath:    "/api/auth",
			},
			expectedPath: "/api/auth",
		},
		{
			name: "defaults to / when BasePath empty",
			spec: domain.DeploySpec{
				ProjectName: "myapp",
				Domain:      "myapp.threesix.ai",
				Port:        8080,
				BasePath:    "",
			},
			expectedPath: "/",
		},
		{
			name: "root path explicit",
			spec: domain.DeploySpec{
				ProjectName: "myapp",
				Domain:      "myapp.threesix.ai",
				Port:        3000,
				BasePath:    "/",
			},
			expectedPath: "/",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			ingress := d.buildIngress(tc.spec, "projects", pathType, "traefik", "myapp-tls", nil)

			if len(ingress.Spec.Rules) != 1 {
				t.Fatalf("expected 1 rule, got %d", len(ingress.Spec.Rules))
			}
			if len(ingress.Spec.Rules[0].HTTP.Paths) != 1 {
				t.Fatalf("expected 1 path, got %d", len(ingress.Spec.Rules[0].HTTP.Paths))
			}

			actualPath := ingress.Spec.Rules[0].HTTP.Paths[0].Path
			if actualPath != tc.expectedPath {
				t.Errorf("path = %q, want %q", actualPath, tc.expectedPath)
			}
		})
	}
}

func TestBuildIngress_Labels(t *testing.T) {
	d := &Deployer{config: Config{IngressClass: "traefik"}}
	pathType := networkingv1.PathTypePrefix

	t.Run("includes component label for monorepo component", func(t *testing.T) {
		spec := domain.DeploySpec{
			ProjectName:   "myapp",
			ComponentPath: "services/api",
			Domain:        "myapp.threesix.ai",
			Port:          8080,
		}

		ingress := d.buildIngress(spec, "projects", pathType, "traefik", "myapp-tls", nil)

		if ingress.Labels["component"] != "services-api" {
			t.Errorf("component label = %q, want %q", ingress.Labels["component"], "services-api")
		}
		if ingress.Labels["project"] != "myapp" {
			t.Errorf("project label = %q, want %q", ingress.Labels["project"], "myapp")
		}
	})

	t.Run("no component label for single-app project", func(t *testing.T) {
		spec := domain.DeploySpec{
			ProjectName: "myapp",
			Domain:      "myapp.threesix.ai",
			Port:        8080,
		}

		ingress := d.buildIngress(spec, "projects", pathType, "traefik", "myapp-tls", nil)

		if _, ok := ingress.Labels["component"]; ok {
			t.Error("component label should not be present for single-app project")
		}
	})
}

func TestSortIngressPaths(t *testing.T) {
	tests := []struct {
		name     string
		input    []string
		expected []string
	}{
		{
			name:     "sorts by length descending",
			input:    []string{"/", "/api", "/api/auth"},
			expected: []string{"/api/auth", "/api", "/"},
		},
		{
			name:     "same length sorted alphabetically",
			input:    []string{"/api", "/web"},
			expected: []string{"/api", "/web"},
		},
		{
			name:     "already sorted",
			input:    []string{"/api/auth", "/api", "/"},
			expected: []string{"/api/auth", "/api", "/"},
		},
		{
			name:     "empty list",
			input:    []string{},
			expected: []string{},
		},
		{
			name:     "single item",
			input:    []string{"/api"},
			expected: []string{"/api"},
		},
		{
			name:     "reverse order",
			input:    []string{"/", "/a", "/ab", "/abc"},
			expected: []string{"/abc", "/ab", "/a", "/"},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			pathType := networkingv1.PathTypePrefix
			paths := make([]networkingv1.HTTPIngressPath, len(tc.input))
			for i, p := range tc.input {
				paths[i] = networkingv1.HTTPIngressPath{
					Path:     p,
					PathType: &pathType,
				}
			}

			sortIngressPaths(paths)

			for i, expected := range tc.expected {
				if paths[i].Path != expected {
					t.Errorf("paths[%d] = %q, want %q", i, paths[i].Path, expected)
				}
			}
		})
	}
}

func TestSanitizeLabelValue(t *testing.T) {
	tests := []struct {
		input    string
		expected string
	}{
		{"services/api", "services-api"},
		{"apps/web", "apps-web"},
		{"services/auth-api", "services-auth-api"},
		{"simple", "simple"},
		{"", ""},
	}

	for _, tc := range tests {
		t.Run(tc.input, func(t *testing.T) {
			result := sanitizeLabelValue(tc.input)
			if result != tc.expected {
				t.Errorf("sanitizeLabelValue(%q) = %q, want %q", tc.input, result, tc.expected)
			}
		})
	}
}

// =============================================================================
// AddIngressPath Tests
// =============================================================================

func TestAddIngressPath_CreatesNewIngress(t *testing.T) {
	mock := newMockIngressClient()
	d := NewDeployerWithIngressClient(nil, mock, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
		TLSIssuer:    "letsencrypt",
	})
	ctx := context.Background()

	err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api/auth", "myapp-auth", 8001)
	if err != nil {
		t.Fatalf("AddIngressPath failed: %v", err)
	}

	ingress, err := mock.GetIngress(ctx, "projects", "myapp")
	if err != nil {
		t.Fatalf("Failed to get created ingress: %v", err)
	}

	// Verify ingress structure
	if ingress.Name != "myapp" {
		t.Errorf("ingress name = %q, want %q", ingress.Name, "myapp")
	}
	if ingress.Labels["project"] != "myapp" {
		t.Errorf("project label = %q, want %q", ingress.Labels["project"], "myapp")
	}
	if len(ingress.Spec.Rules) != 1 {
		t.Fatalf("expected 1 rule, got %d", len(ingress.Spec.Rules))
	}
	if ingress.Spec.Rules[0].Host != "myapp.threesix.ai" {
		t.Errorf("host = %q, want %q", ingress.Spec.Rules[0].Host, "myapp.threesix.ai")
	}
	if len(ingress.Spec.Rules[0].HTTP.Paths) != 1 {
		t.Fatalf("expected 1 path, got %d", len(ingress.Spec.Rules[0].HTTP.Paths))
	}
	path := ingress.Spec.Rules[0].HTTP.Paths[0]
	if path.Path != "/api/auth" {
		t.Errorf("path = %q, want %q", path.Path, "/api/auth")
	}
	if path.Backend.Service.Name != "myapp-auth" {
		t.Errorf("service name = %q, want %q", path.Backend.Service.Name, "myapp-auth")
	}
	if path.Backend.Service.Port.Number != 8001 {
		t.Errorf("port = %d, want %d", path.Backend.Service.Port.Number, 8001)
	}

	// Verify TLS
	if len(ingress.Spec.TLS) != 1 {
		t.Fatalf("expected 1 TLS entry, got %d", len(ingress.Spec.TLS))
	}
	if ingress.Spec.TLS[0].Hosts[0] != "myapp.threesix.ai" {
		t.Errorf("TLS host = %q, want %q", ingress.Spec.TLS[0].Hosts[0], "myapp.threesix.ai")
	}

	// Verify annotations
	if ingress.Annotations["cert-manager.io/cluster-issuer"] != "letsencrypt" {
		t.Errorf("cluster-issuer annotation = %q, want %q", ingress.Annotations["cert-manager.io/cluster-issuer"], "letsencrypt")
	}
}

func TestAddIngressPath_AddsPathToExistingIngress(t *testing.T) {
	mock := newMockIngressClient()
	d := NewDeployerWithIngressClient(nil, mock, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Create initial path
	err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/", "myapp-web", 3000)
	if err != nil {
		t.Fatalf("initial AddIngressPath failed: %v", err)
	}

	// Add second path
	err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
	if err != nil {
		t.Fatalf("second AddIngressPath failed: %v", err)
	}

	ingress, err := mock.GetIngress(ctx, "projects", "myapp")
	if err != nil {
		t.Fatalf("Failed to get ingress: %v", err)
	}

	// Should have 2 paths, sorted by specificity (longer first)
	paths := ingress.Spec.Rules[0].HTTP.Paths
	if len(paths) != 2 {
		t.Fatalf("expected 2 paths, got %d", len(paths))
	}
	if paths[0].Path != "/api" {
		t.Errorf("first path = %q, want %q (longer path first)", paths[0].Path, "/api")
	}
	if paths[1].Path != "/" {
		t.Errorf("second path = %q, want %q", paths[1].Path, "/")
	}
}

func TestAddIngressPath_UpdatesExistingPath(t *testing.T) {
	mock := newMockIngressClient()
	d := NewDeployerWithIngressClient(nil, mock, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Create initial path
	err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api-v1", 8080)
	if err != nil {
		t.Fatalf("initial AddIngressPath failed: %v", err)
	}

	// Update same path with different service
	err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api-v2", 8081)
	if err != nil {
		t.Fatalf("update AddIngressPath failed: %v", err)
	}

	ingress, err := mock.GetIngress(ctx, "projects", "myapp")
	if err != nil {
		t.Fatalf("Failed to get ingress: %v", err)
	}

	// Should still have only 1 path (updated, not duplicated)
	paths := ingress.Spec.Rules[0].HTTP.Paths
	if len(paths) != 1 {
		t.Fatalf("expected 1 path, got %d", len(paths))
	}
	if paths[0].Backend.Service.Name != "myapp-api-v2" {
		t.Errorf("service = %q, want %q", paths[0].Backend.Service.Name, "myapp-api-v2")
	}
	if paths[0].Backend.Service.Port.Number != 8081 {
		t.Errorf("port = %d, want %d", paths[0].Backend.Service.Port.Number, 8081)
	}
}

func TestAddIngressPath_RetriesOnConflict(t *testing.T) {
	// Use conflictNTimesClient which returns conflict errors N times then succeeds
	conflictClient := newConflictNTimesClient(2) // Fail twice, succeed on third
	d := NewDeployerWithIngressClient(nil, conflictClient, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Pre-create ingress so update is called (not create)
	_, err := conflictClient.CreateIngress(ctx, "projects", &networkingv1.Ingress{
		ObjectMeta: networkingv1.Ingress{}.ObjectMeta,
	})
	// Reset - manually create the ingress via the underlying mock
	conflictClient.mockIngressClient.mu.Lock()
	pathType := networkingv1.PathTypePrefix
	conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{
		ObjectMeta: networkingv1.Ingress{}.ObjectMeta,
		Spec: networkingv1.IngressSpec{
			Rules: []networkingv1.IngressRule{
				{
					Host: "myapp.threesix.ai",
					IngressRuleValue: networkingv1.IngressRuleValue{
						HTTP: &networkingv1.HTTPIngressRuleValue{
							Paths: []networkingv1.HTTPIngressPath{
								{Path: "/", PathType: &pathType},
							},
						},
					},
				},
			},
		},
	}
	conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp"
	conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1"
	conflictClient.mockIngressClient.mu.Unlock()

	err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
	if err != nil {
		t.Fatalf("should succeed after retries: %v", err)
	}

	// Should have called update 3 times (2 failures + 1 success)
	if conflictClient.UpdateCallCount() != 3 {
		t.Errorf("expected 3 update calls (2 failures + 1 success), got %d", conflictClient.UpdateCallCount())
	}
}

func TestAddIngressPath_FailsAfterMaxRetries(t *testing.T) {
	// Use conflictNTimesClient which always returns conflict
	conflictClient := newConflictNTimesClient(maxIngressRetries + 1) // Always fail
	d := NewDeployerWithIngressClient(nil, conflictClient, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Pre-create ingress
	conflictClient.mockIngressClient.mu.Lock()
	pathType := networkingv1.PathTypePrefix
	conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{
		Spec: networkingv1.IngressSpec{
			Rules: []networkingv1.IngressRule{
				{
					Host: "myapp.threesix.ai",
					IngressRuleValue: networkingv1.IngressRuleValue{
						HTTP: &networkingv1.HTTPIngressRuleValue{
							Paths: []networkingv1.HTTPIngressPath{
								{Path: "/", PathType: &pathType},
							},
						},
					},
				},
			},
		},
	}
	conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp"
	conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1"
	conflictClient.mockIngressClient.mu.Unlock()

	err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
	if err == nil {
		t.Fatal("expected error after max retries")
	}
	if conflictClient.UpdateCallCount() != maxIngressRetries {
		t.Errorf("expected %d update calls, got %d", maxIngressRetries, conflictClient.UpdateCallCount())
	}
}

func TestAddIngressPath_DoesNotRetryNonConflictErrors(t *testing.T) {
	mock := newMockIngressClient()
	// Set a non-conflict error
	mock.getErr = fmt.Errorf("network error")
	d := NewDeployerWithIngressClient(nil, mock, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
	if err == nil {
		t.Fatal("expected error")
	}

	// Should only call Get once (no retry for non-conflict errors)
	if mock.getCalls.Load() != 1 {
		t.Errorf("expected 1 get call, got %d", mock.getCalls.Load())
	}
}

// =============================================================================
// RemoveIngressPath Tests
// =============================================================================

func TestRemoveIngressPath_RemovesPath(t *testing.T) {
	mock := newMockIngressClient()
	d := NewDeployerWithIngressClient(nil, mock, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Create ingress with two paths
	_ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/", "myapp-web", 3000)
	_ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)

	// Remove one path
	err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api")
	if err != nil {
		t.Fatalf("RemoveIngressPath failed: %v", err)
	}

	ingress, _ := mock.GetIngress(ctx, "projects", "myapp")
	paths := ingress.Spec.Rules[0].HTTP.Paths
	if len(paths) != 1 {
		t.Fatalf("expected 1 path remaining, got %d", len(paths))
	}
	if paths[0].Path != "/" {
		t.Errorf("remaining path = %q, want %q", paths[0].Path, "/")
	}
}

func TestRemoveIngressPath_DeletesIngressWhenEmpty(t *testing.T) {
	mock := newMockIngressClient()
	d := NewDeployerWithIngressClient(nil, mock, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Create ingress with single path
	_ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)

	// Remove the only path
	err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api")
	if err != nil {
		t.Fatalf("RemoveIngressPath failed: %v", err)
	}

	// Ingress should be deleted
	_, err = mock.GetIngress(ctx, "projects", "myapp")
	if !errors.IsNotFound(err) {
		t.Errorf("expected NotFound error, got %v", err)
	}
}

func TestRemoveIngressPath_NotFoundIsNoOp(t *testing.T) {
	mock := newMockIngressClient()
	d := NewDeployerWithIngressClient(nil, mock, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Remove from non-existent ingress - should not error
	err := d.RemoveIngressPath(ctx, "nonexistent", "nonexistent.threesix.ai", "/api")
	if err != nil {
		t.Errorf("expected no error for non-existent ingress, got %v", err)
	}
}

func TestRemoveIngressPath_RetriesOnConflict(t *testing.T) {
	conflictClient := newConflictNTimesClient(1) // Fail once, succeed on second
	d := NewDeployerWithIngressClient(nil, conflictClient, Config{
		Namespace:    "projects",
		IngressClass: "traefik",
	})
	ctx := context.Background()

	// Pre-create ingress with two paths
	conflictClient.mockIngressClient.mu.Lock()
	pathType := networkingv1.PathTypePrefix
	conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{
		Spec: networkingv1.IngressSpec{
			Rules: []networkingv1.IngressRule{
				{
					Host: "myapp.threesix.ai",
					IngressRuleValue: networkingv1.IngressRuleValue{
						HTTP: &networkingv1.HTTPIngressRuleValue{
							Paths: []networkingv1.HTTPIngressPath{
								{Path: "/", PathType: &pathType},
								{Path: "/api", PathType: &pathType},
							},
						},
					},
				},
			},
		},
	}
	conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp"
	conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1"
	conflictClient.mockIngressClient.mu.Unlock()

	err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api")
	if err != nil {
		t.Fatalf("should succeed after retry: %v", err)
	}

	// Should have called update 2 times (1 failure + 1 success)
	if conflictClient.UpdateCallCount() != 2 {
		t.Errorf("expected 2 update calls, got %d", conflictClient.UpdateCallCount())
	}
}

// =============================================================================
// retryOnConflict Tests
// =============================================================================

func TestRetryOnConflict_SucceedsImmediately(t *testing.T) {
	calls := 0
	err := retryOnConflict(context.Background(), func() error {
		calls++
		return nil
	})
	if err != nil {
		t.Errorf("unexpected error: %v", err)
	}
	if calls != 1 {
		t.Errorf("expected 1 call, got %d", calls)
	}
}

func TestRetryOnConflict_RetriesConflictErrors(t *testing.T) {
	calls := 0
	err := retryOnConflict(context.Background(), func() error {
		calls++
		if calls < 3 {
			return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict"))
		}
		return nil
	})
	if err != nil {
		t.Errorf("unexpected error: %v", err)
	}
	if calls != 3 {
		t.Errorf("expected 3 calls, got %d", calls)
	}
}

func TestRetryOnConflict_FailsAfterMaxRetries(t *testing.T) {
	calls := 0
	err := retryOnConflict(context.Background(), func() error {
		calls++
		return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict"))
	})
	if err == nil {
		t.Error("expected error")
	}
	if calls != maxIngressRetries {
		t.Errorf("expected %d calls, got %d", maxIngressRetries, calls)
	}
}

func TestRetryOnConflict_DoesNotRetryNonConflictErrors(t *testing.T) {
	calls := 0
	err := retryOnConflict(context.Background(), func() error {
		calls++
		return fmt.Errorf("some other error")
	})
	if err == nil {
		t.Error("expected error")
	}
	if calls != 1 {
		t.Errorf("expected 1 call (no retry for non-conflict), got %d", calls)
	}
}

func TestRetryOnConflict_RespectsContextCancellation(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	calls := 0

	// Cancel after first call
	go func() {
		time.Sleep(50 * time.Millisecond)
		cancel()
	}()

	err := retryOnConflict(ctx, func() error {
		calls++
		return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict"))
	})

	if err != context.Canceled {
		t.Errorf("expected context.Canceled, got %v", err)
	}
}
