package service

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"metis/pkg/sentinel"
)

func TestUIAuthGuardsState(t *testing.T) {
	app := newTestApp(t)
	handler := app.Handler()

	req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusForbidden {
		t.Fatalf("expected forbidden, got %d", resp.Code)
	}

	req = httptest.NewRequest(http.MethodGet, "/api/state", nil)
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusOK {
		t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
	}
}

func TestUIAuthAcceptsForwardedSlashGroups(t *testing.T) {
	app := newTestApp(t)
	handler := app.Handler()

	req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
	req.Header.Set("X-Forwarded-User", "brad")
	req.Header.Set("X-Forwarded-Groups", "/admin,/ops")
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusOK {
		t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
	}
}

func TestUIAuthRejectsUserWithoutAllowedGroup(t *testing.T) {
	app := newTestApp(t)
	handler := app.Handler()

	req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
	req.Header.Set("X-Forwarded-Email", "Brad.Stein@gmail.com")
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusForbidden {
		t.Fatalf("expected forbidden, got %d: %s", resp.Code, resp.Body.String())
	}
}

func TestStateJSONUsesLowerCaseNodeFields(t *testing.T) {
	app := newTestApp(t)
	handler := app.Handler()

	req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusOK {
		t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String())
	}
	body := resp.Body.String()
	if !strings.Contains(body, `"name":"titan-15"`) {
		t.Fatalf("expected lowercase node name field in json, got %s", body)
	}
}

func TestStateFiltersNodesWithoutCompleteReplacementDefinition(t *testing.T) {
	dir := t.TempDir()
	baseImage := filepath.Join(dir, "base.img")
	if err := os.WriteFile(baseImage, []byte("test-image"), 0o644); err != nil {
		t.Fatal(err)
	}
	sum := sha256.Sum256([]byte("test-image"))
	inventoryPath := filepath.Join(dir, "inventory.yaml")
	inv := `
classes:
  - name: ready
    arch: arm64
    os: armbian
    image: file://` + baseImage + `
    checksum: sha256:` + hex.EncodeToString(sum[:]) + `
    k3s_version: v1.31.5+k3s1
  - name: incomplete
    arch: arm64
    os: ubuntu
    image: file://` + baseImage + `
nodes:
  - name: titan-ready
    class: ready
    hostname: titan-ready
    ip: 192.168.22.240
    k3s_role: agent
    k3s_url: https://192.168.22.7:6443
    k3s_token: token
    ssh_user: atlas
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion
  - name: titan-incomplete
    class: incomplete
    hostname: titan-incomplete
    ip: 192.168.22.241
    k3s_role: agent
    k3s_url: https://192.168.22.7:6443
    k3s_token: token
    ssh_user: atlas
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion
`
	if err := os.WriteFile(inventoryPath, []byte(inv), 0o644); err != nil {
		t.Fatal(err)
	}
	settings := Settings{
		BindAddr:         ":0",
		InventoryPath:    inventoryPath,
		CacheDir:         filepath.Join(dir, "cache"),
		ArtifactDir:      filepath.Join(dir, "artifacts"),
		HistoryPath:      filepath.Join(dir, "history.jsonl"),
		SnapshotsPath:    filepath.Join(dir, "snapshots.json"),
		TargetsPath:      filepath.Join(dir, "targets.json"),
		DefaultFlashHost: "titan-22",
		FlashHosts:       []string{"titan-22"},
		LocalHost:        "titan-22",
		AllowedGroups:    []string{"admin", "maintenance"},
		MaxDeviceBytes:   300000000000,
	}
	app, err := NewApp(settings)
	if err != nil {
		t.Fatalf("new app: %v", err)
	}
	state := app.State("titan-22")
	if len(state.Nodes) != 1 || state.Nodes[0].Name != "titan-ready" {
		t.Fatalf("expected only titan-ready in state nodes, got %+v", state.Nodes)
	}
}

func TestInternalSnapshotAndWatch(t *testing.T) {
	app := newTestApp(t)
	handler := app.Handler()

	payload := `{"node":"titan-15","collected_at":"2026-03-31T12:00:00Z","snapshot":{"hostname":"titan-15","kernel":"6.6.63","os_image":"Armbian","k3s_version":"v1.31.5+k3s1","containerd":"2.0.0","package_sample":{"containerd":"2.0.0"}}}`
	req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusOK {
		t.Fatalf("snapshot failed: %d %s", resp.Code, resp.Body.String())
	}

	req = httptest.NewRequest(http.MethodPost, "/internal/sentinel/watch", nil)
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusOK {
		t.Fatalf("watch failed: %d %s", resp.Code, resp.Body.String())
	}

	var event Event
	if err := json.Unmarshal(resp.Body.Bytes(), &event); err != nil {
		t.Fatalf("decode watch response: %v", err)
	}
	if event.Kind != "sentinel.watch" {
		t.Fatalf("unexpected event kind: %s", event.Kind)
	}

	metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
	metricsResp := httptest.NewRecorder()
	handler.ServeHTTP(metricsResp, metricsReq)
	body := metricsResp.Body.String()
	if !strings.Contains(body, `metis_sentinel_snapshots_total{node="titan-15",status="ok"} 1`) {
		t.Fatalf("missing snapshot metric: %s", body)
	}
	if !strings.Contains(body, `metis_sentinel_watch_total{status="ok"} 1`) {
		t.Fatalf("missing watch metric: %s", body)
	}
}

func TestRequestValuesJSONBody(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/api/jobs/replace", strings.NewReader(`{"node":"titan-13","host":"titan-20","device":"hosttmp:///tmp"}`))
	req.Header.Set("Content-Type", "application/json")

	values := requestValues(req)
	if values["node"] != "titan-13" {
		t.Fatalf("expected node titan-13, got %q", values["node"])
	}
	if values["host"] != "titan-20" {
		t.Fatalf("expected host titan-20, got %q", values["host"])
	}
	if values["device"] != "hosttmp:///tmp" {
		t.Fatalf("expected device hosttmp:///tmp, got %q", values["device"])
	}
}

func TestRequestValuesFormAndAuthHelpers(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/api/jobs/replace", strings.NewReader("node=titan-13&host=titan-20&device=/dev/sdz"))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	values := requestValues(req)
	if values["device"] != "/dev/sdz" {
		t.Fatalf("form requestValues = %#v", values)
	}

	app := newTestApp(t)
	req = httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	if user, ok := app.authorize(req); !ok || user.Name != "brad" {
		t.Fatalf("authorize = %#v ok=%v", user, ok)
	}
	req = httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set("X-Forwarded-User", "brad")
	req.Header.Set("X-Forwarded-Groups", "/admin")
	if user, ok := app.authorize(req); !ok || user.Name != "brad" {
		t.Fatalf("forwarded authorize = %#v ok=%v", user, ok)
	}
}

func TestHandleBuildReturnsConflictForDuplicateActiveNodeJob(t *testing.T) {
	app := newTestApp(t)
	app.newJob("build", "titan-15", "", "")
	handler := app.Handler()
	req := httptest.NewRequest(http.MethodPost, "/api/jobs/build", strings.NewReader(`{"node":"titan-15"}`))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusConflict {
		t.Fatalf("expected conflict, got %d: %s", resp.Code, resp.Body.String())
	}
}

func TestHTTPHandlersExerciseErrorBranches(t *testing.T) {
	kube := fakeKubeServer(t)
	installKubeFactory(t, kube)
	app := newTestApp(t)
	app.settings.RunnerImageARM64 = ""
	app.settings.TargetsPath = t.TempDir()
	handler := app.Handler()

	req := httptest.NewRequest(http.MethodPut, "/internal/sentinel/snapshot", nil)
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusMethodNotAllowed {
		t.Fatalf("expected method not allowed, got %d", resp.Code)
	}

	req = httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader("{"))
	req.Header.Set("Content-Type", "application/json")
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusBadRequest {
		t.Fatalf("expected bad request, got %d", resp.Code)
	}

	req = httptest.NewRequest(http.MethodGet, "/api/devices?host=titan-22", nil)
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusBadRequest {
		t.Fatalf("expected bad request from missing runner image, got %d", resp.Code)
	}

	req = httptest.NewRequest(http.MethodPost, "/api/jobs/build", strings.NewReader("node="))
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusBadRequest {
		t.Fatalf("expected build validation error, got %d", resp.Code)
	}

	req = httptest.NewRequest(http.MethodPost, "/api/jobs/replace", strings.NewReader("node=titan-15"))
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusBadRequest {
		t.Fatalf("expected replace validation error, got %d", resp.Code)
	}

	req = httptest.NewRequest(http.MethodPost, "/api/sentinel/watch", nil)
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusInternalServerError {
		t.Fatalf("expected watch error from unwritable targets path, got %d", resp.Code)
	}

	_, ok := app.authorize(httptest.NewRequest(http.MethodGet, "/", nil))
	if ok {
		t.Fatal("authorize should return false for empty headers")
	}
	if got := splitHeaderList("a, /b"); len(got) != 2 || got[0] != "a" || got[1] != "/b" {
		t.Fatal("splitHeaderList failed")
	}
	values := requestValues(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"node":"n1"}`)))
	if values["node"] != "n1" {
		t.Fatalf("requestValues JSON parse failed: %#v", values)
	}
}

func TestHTTPHandlersAdditionalErrorBranches(t *testing.T) {
	app := newTestApp(t)
	handler := app.Handler()

	blocked := filepath.Join(t.TempDir(), "blocked")
	if err := os.WriteFile(blocked, []byte("block"), 0o644); err != nil {
		t.Fatal(err)
	}
	app.settings.SnapshotsPath = filepath.Join(blocked, "snapshots.json")
	payload := `{"node":"titan-15","snapshot":{"hostname":"titan-15"}}`
	req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader(payload))
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusBadRequest {
		t.Fatalf("expected snapshot persistence error, got %d", resp.Code)
	}

	for _, path := range []string{"/internal/sentinel/watch", "/api/jobs/build", "/api/jobs/replace", "/api/sentinel/watch"} {
		req = httptest.NewRequest(http.MethodGet, path, nil)
		req.Header.Set("X-Auth-Request-User", "brad")
		req.Header.Set("X-Auth-Request-Groups", "admin")
		resp = httptest.NewRecorder()
		handler.ServeHTTP(resp, req)
		if resp.Code != http.StatusMethodNotAllowed {
			t.Fatalf("expected method-not-allowed for %s, got %d", path, resp.Code)
		}
	}

	req = httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "viewers")
	if _, ok := app.authorize(req); ok {
		t.Fatal("authorize should reject non-allowed groups")
	}
}

func TestWatchHandlersReturnErrorsWhenTargetsCannotPersist(t *testing.T) {
	app := newTestApp(t)
	blocked := filepath.Join(t.TempDir(), "blocked")
	if err := os.WriteFile(blocked, []byte("block"), 0o644); err != nil {
		t.Fatal(err)
	}
	app.settings.TargetsPath = filepath.Join(blocked, "targets.json")
	handler := app.Handler()

	req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/watch", nil)
	resp := httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusInternalServerError {
		t.Fatalf("expected internal watch error, got %d", resp.Code)
	}

	req = httptest.NewRequest(http.MethodPost, "/api/sentinel/watch", nil)
	req.Header.Set("X-Auth-Request-User", "brad")
	req.Header.Set("X-Auth-Request-Groups", "admin")
	resp = httptest.NewRecorder()
	handler.ServeHTTP(resp, req)
	if resp.Code != http.StatusInternalServerError {
		t.Fatalf("expected api watch error, got %d", resp.Code)
	}
}

func newTestApp(t *testing.T) *App {
	t.Helper()
	dir := t.TempDir()
	baseImage := filepath.Join(dir, "base.img")
	if err := os.WriteFile(baseImage, []byte("test-image"), 0o644); err != nil {
		t.Fatal(err)
	}
	sum := sha256.Sum256([]byte("test-image"))
	inventoryPath := filepath.Join(dir, "inventory.yaml")
	inv := `
classes:
  - name: rpi4
    arch: arm64
    os: armbian
    image: file://` + baseImage + `
    checksum: sha256:` + hex.EncodeToString(sum[:]) + `
    k3s_version: v1.31.5+k3s1
nodes:
  - name: titan-15
    class: rpi4
    hostname: titan-15
    ip: 192.168.22.43
    k3s_role: agent
    k3s_url: https://192.168.22.7:6443
    k3s_token: token
    ssh_user: atlas
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion
`
	if err := os.WriteFile(inventoryPath, []byte(inv), 0o644); err != nil {
		t.Fatal(err)
	}
	settings := Settings{
		BindAddr:         ":0",
		InventoryPath:    inventoryPath,
		CacheDir:         filepath.Join(dir, "cache"),
		ArtifactDir:      filepath.Join(dir, "artifacts"),
		HistoryPath:      filepath.Join(dir, "history.jsonl"),
		SnapshotsPath:    filepath.Join(dir, "snapshots.json"),
		TargetsPath:      filepath.Join(dir, "targets.json"),
		DefaultFlashHost: "titan-22",
		FlashHosts:       []string{"titan-22"},
		LocalHost:        "titan-22",
		AllowedGroups:    []string{"admin", "maintainer"},
		MaxDeviceBytes:   300000000000,
	}
	app, err := NewApp(settings)
	if err != nil {
		t.Fatalf("new app: %v", err)
	}
	if err := app.StoreSnapshot(SnapshotRecord{
		Node:        "titan-17",
		CollectedAt: time.Now().UTC().Add(-10 * time.Minute),
		Snapshot:    sentinelSnapshot("titan-17", "6.6.63"),
	}); err != nil {
		t.Fatalf("seed snapshot: %v", err)
	}
	return app
}

func sentinelSnapshot(hostname, kernel string) sentinel.Snapshot {
	return sentinel.Snapshot{
		Hostname:      hostname,
		Kernel:        kernel,
		OSImage:       "Armbian",
		K3sVersion:    "v1.31.5+k3s1",
		Containerd:    "2.0.0",
		PackageSample: map[string]string{"containerd": "2.0.0"},
	}
}
