package api

import (
	"encoding/json"
	"net/http"
	"testing"

	"github.com/alexei/tinyforge/internal/store"
)

// =============================================================================
// stripImageTag — pure helper, no fixtures needed
// =============================================================================

func TestStripImageTag(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want string
	}{
		{"empty", "", ""},
		{"bare", "nginx", "nginx"},
		{"tagged", "nginx:1.25", "nginx"},
		{"latest", "nginx:latest", "nginx"},
		{"owner_tagged", "library/nginx:1.25", "library/nginx"},
		{"registry_tagged", "registry.example.com/owner/app:v1", "registry.example.com/owner/app"},
		{"registry_port_no_tag", "registry.example.com:5000/owner/app", "registry.example.com:5000/owner/app"},
		{"registry_port_with_tag", "registry.example.com:5000/owner/app:v1", "registry.example.com:5000/owner/app"},
		{"digest", "nginx@sha256:abcd", "nginx"},
		{"digest_with_owner", "library/nginx@sha256:abcd", "library/nginx"},
		{"trailing_whitespace", "  nginx:1.25  ", "nginx"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := stripImageTag(tc.in)
			if got != tc.want {
				t.Errorf("stripImageTag(%q) = %q, want %q", tc.in, got, tc.want)
			}
		})
	}
}

// =============================================================================
// imageRefFromSourceConfig — pure helper
// =============================================================================

func TestImageRefFromSourceConfig(t *testing.T) {
	cases := []struct {
		name string
		raw  string
		want string
	}{
		{"empty", "", ""},
		{"malformed", "{not json", ""},
		{"no_image_field", `{"port":8080}`, ""},
		{"basic", `{"image":"nginx:1.25"}`, "nginx:1.25"},
		{"whitespace_trim", `{"image":"  nginx:1.25  "}`, "nginx:1.25"},
		{"with_extras", `{"image":"nginx","port":8080,"env":{"K":"v"}}`, "nginx"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := imageRefFromSourceConfig(tc.raw)
			if got != tc.want {
				t.Errorf("imageRefFromSourceConfig(%q) = %q, want %q", tc.raw, got, tc.want)
			}
		})
	}
}

// =============================================================================
// GET /api/discovery/image/conflicts
// =============================================================================

// seedImageWorkload inserts a plugin-shaped image workload via the store
// directly. We bypass the API here so each test case starts with a
// known fixture independent of /api/workloads create-path behaviour.
func seedImageWorkload(t *testing.T, st *store.Store, name, imageRef string) {
	t.Helper()
	cfg, err := json.Marshal(map[string]any{"image": imageRef, "port": 8080})
	if err != nil {
		t.Fatalf("marshal source_config: %v", err)
	}
	if _, err := st.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindProject),
		Name:         name,
		SourceKind:   "image",
		SourceConfig: string(cfg),
	}); err != nil {
		t.Fatalf("seed workload %q: %v", name, err)
	}
}

func TestListImageConflicts_NoMatches_ReturnsEmpty(t *testing.T) {
	e := newAPITestEnv(t)
	seedImageWorkload(t, e.store, "alpha", "nginx:1.25")
	seedImageWorkload(t, e.store, "beta", "registry.example.com/owner/web:v2")

	resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=postgres:16", nil)
	if resp.StatusCode != http.StatusOK {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got []imageConflict
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if len(got) != 0 {
		t.Errorf("expected 0 conflicts, got %d: %+v", len(got), got)
	}
}

func TestListImageConflicts_TagMismatch_StillCollides(t *testing.T) {
	// The legacy quickDeploy collided on repo without tag so nginx:1.25
	// surfaces nginx:1.26 — this preserves that behaviour.
	e := newAPITestEnv(t)
	seedImageWorkload(t, e.store, "nginx-prod", "nginx:1.25")
	seedImageWorkload(t, e.store, "nginx-latest", "nginx:latest")

	resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=nginx:1.26", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got []imageConflict
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if len(got) != 2 {
		t.Fatalf("expected 2 conflicts, got %d: %+v", len(got), got)
	}
	names := map[string]bool{}
	for _, c := range got {
		names[c.Name] = true
	}
	if !names["nginx-prod"] || !names["nginx-latest"] {
		t.Errorf("expected both nginx-prod and nginx-latest in conflicts, got %+v", got)
	}
}

func TestListImageConflicts_RegistryPortPreserved(t *testing.T) {
	// Make sure stripImageTag preserves a registry port in the host
	// segment — registry.example.com:5000/owner/app:v1 must collide
	// only with refs whose repo is registry.example.com:5000/owner/app.
	e := newAPITestEnv(t)
	seedImageWorkload(t, e.store, "with-port", "registry.example.com:5000/owner/app:v1")
	seedImageWorkload(t, e.store, "no-port", "owner/app:v1")

	resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=registry.example.com:5000/owner/app:v2", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got []imageConflict
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if len(got) != 1 || got[0].Name != "with-port" {
		t.Errorf("expected sole conflict on with-port, got %+v", got)
	}
}

func TestListImageConflicts_NonImageSourceIgnored(t *testing.T) {
	// Static-source workloads must never appear in image conflicts even
	// if their JSON happens to contain a stray "image" key — guard
	// against source_kind != "image" rows.
	e := newAPITestEnv(t)
	if _, err := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindProject),
		Name:         "static-with-image-key",
		SourceKind:   "static",
		SourceConfig: `{"image":"nginx:1.25","provider":"gitea"}`,
	}); err != nil {
		t.Fatalf("seed static workload: %v", err)
	}

	resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts?image=nginx:1.25", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got []imageConflict
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if len(got) != 0 {
		t.Errorf("expected 0 conflicts (static source filtered out), got %+v", got)
	}
}

func TestListImageConflicts_MissingImageParam_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodGet, "/api/discovery/image/conflicts", nil)
	if resp.StatusCode != http.StatusBadRequest {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

// =============================================================================
// POST /api/discovery/git/* — input validation
// =============================================================================
//
// These tests only assert request-shape validation. The provider
// implementations themselves are exercised by their own tests in
// internal/staticsite; we don't reach upstream Git in unit tests.

func TestDetectGitProvider_MissingBaseURL_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/detect-provider", map[string]string{})
	if resp.StatusCode != http.StatusBadRequest {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestTestGitConnection_MissingRepo_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/test-connection", map[string]string{
		"base_url": "https://git.example.com",
	})
	if resp.StatusCode != http.StatusBadRequest {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestListGitBranches_MissingRepo_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/branches", map[string]string{
		"base_url": "https://git.example.com",
	})
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestListGitTree_MissingBranch_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/tree", map[string]string{
		"base_url":   "https://git.example.com",
		"repo_owner": "owner",
		"repo_name":  "repo",
	})
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestListGitRepos_MissingBaseURL_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/repos", map[string]string{})
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

// =============================================================================
// Validators added during security hardening — boundary checks the
// providers depend on for safe URL interpolation.
// =============================================================================

func TestValidateGitIdent(t *testing.T) {
	cases := []struct {
		name      string
		input     string
		wantError bool
	}{
		{"ok_simple", "owner", false},
		{"ok_with_dash", "my-org", false},
		{"ok_with_dot", "user.name", false},
		{"ok_with_underscore", "my_repo", false},
		{"empty", "", true},
		{"whitespace_only", "   ", true},
		{"leading_dot", ".hidden", true},
		{"leading_dash", "-flag", true},
		{"slash", "owner/repo", true},
		{"traversal", "..", true},
		{"path_traversal", "../admin", true},
		{"with_space", "my org", true},
		{"with_special", "owner;rm", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := validateGitIdent("test", tc.input)
			if tc.wantError && err == nil {
				t.Errorf("validateGitIdent(%q) = nil, want error", tc.input)
			}
			if !tc.wantError && err != nil {
				t.Errorf("validateGitIdent(%q) = %v, want nil", tc.input, err)
			}
		})
	}
}

func TestValidateGitBranch(t *testing.T) {
	cases := []struct {
		name      string
		input     string
		wantError bool
	}{
		{"ok_main", "main", false},
		{"ok_master", "master", false},
		{"ok_with_slash", "feature/foo", false},
		{"ok_release_tag", "release/v1.2.3", false},
		{"empty", "", true},
		{"traversal", "feature/..", true},
		{"hidden_traversal", "feature/../admin", true},
		{"leading_dash", "-flag", true},
		{"with_space", "feature/my branch", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := validateGitBranch(tc.input)
			if tc.wantError && err == nil {
				t.Errorf("validateGitBranch(%q) = nil, want error", tc.input)
			}
			if !tc.wantError && err != nil {
				t.Errorf("validateGitBranch(%q) = %v, want nil", tc.input, err)
			}
		})
	}
}

func TestTestGitConnection_InvalidOwner_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/test-connection", map[string]string{
		"base_url":   "https://git.example.com",
		"repo_owner": "../admin",
		"repo_name":  "repo",
	})
	if resp.StatusCode != http.StatusBadRequest {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 400 (traversal rejected)", resp.StatusCode)
	}
}

func TestListGitTree_InvalidBranch_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/tree", map[string]string{
		"base_url":   "https://git.example.com",
		"repo_owner": "owner",
		"repo_name":  "repo",
		"branch":     "feature/../admin",
	})
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400 (branch traversal rejected)", resp.StatusCode)
	}
}

func TestDetectGitProvider_InvalidScheme_400(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/discovery/git/detect-provider", map[string]string{
		"base_url": "ftp://git.example.com",
	})
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400 (non-http scheme rejected)", resp.StatusCode)
	}
}
