package httpapi

// API integration tests. Guarded by OIKOS_TEST_DATABASE_URL (see
// internal/db/integration_test.go); run via `make test-db-all` or plain
// `go test` with the env var set.

import (
	"context"
	"encoding/json"
	"fmt"
	"math/rand"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/dtoro/oikos/internal/config"
	"github.com/dtoro/oikos/internal/adapters/postgres"
	"github.com/dtoro/oikos/internal/core/app"
	"github.com/dtoro/oikos/internal/core/ports/portstest"
	"github.com/jackc/pgx/v5"
)

func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
	t.Helper()
	baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
	if baseURL == "" {
		t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
	}
	ctx := context.Background()

	admin, err := pgx.Connect(ctx, baseURL)
	if err != nil {
		t.Fatalf("connect admin: %v", err)
	}
	dbName := fmt.Sprintf("oikos_api_test_%08x", rand.Int63())
	if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
		admin.Close(ctx)
		t.Fatalf("create test db: %v", err)
	}
	admin.Close(ctx)

	qi := strings.Index(baseURL, "?")
	base, params := baseURL, ""
	if qi >= 0 {
		base, params = baseURL[:qi], baseURL[qi:]
	}
	testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params

	pool, err := db.New(ctx, testURL)
	if err != nil {
		t.Fatalf("connect test db: %v", err)
	}
	t.Cleanup(func() {
		pool.Close()
		admin, err := pgx.Connect(ctx, baseURL)
		if err == nil {
			admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
			admin.Close(ctx)
		}
	})

	// Handler context governs the SSE listener goroutine. Cancel it before
	// the pool closes (cleanups run LIFO, so registering it after the
	// pool-close cleanup makes it run first) — otherwise the listener holds
	// a pooled connection and pool.Close() deadlocks.
	handlerCtx, cancelHandler := context.WithCancel(context.Background())
	t.Cleanup(cancelHandler)

	if err := pool.Migrate(ctx); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
		content, err := os.ReadFile("../../seeds/" + f)
		if err != nil {
			t.Fatalf("read seed %s: %v", f, err)
		}
		name := f
		err = pool.SeedIngest(ctx, name, content,
			func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
				var err error
				switch name {
				case "ontology.yaml":
					_, err = db.IngestOntologySeed(ctx, tx, data)
				case "inventory.yaml":
					_, err = db.IngestInventorySeed(ctx, tx, data)
				case "policy.yaml":
					_, err = db.IngestPolicySeed(ctx, tx, data)
				}
				return err
			})
		if err != nil {
			t.Fatalf("ingest %s: %v", f, err)
		}
	}

	repo := db.NewEntityRepo(pool)
	onto := db.NewOntologyRepo(pool, time.Minute)
	// Provisioning/seeds get real services over fakes/real repos: tests
	// below don't exercise pct_create, but the export endpoint does hit
	// SeedService, and a nil would panic.
	provisioning := app.NewProvisioningService(
		&portstest.FakeProvisioner{}, &portstest.FakeResolver{},
		repo, db.NewRelRepo(pool))
	seeds := app.NewSeedService(db.NewSeedRepo(pool))
	return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds)
}

// testAuthToken is the static bearer token devConfig() configures. There is
// no dev-open bypass (removed — plans/2026-07-12-wails-desktop-app.md 0.4),
// so every test handler needs a real credential; get/postJSON/do inject it
// by default. Pass an explicit "" value for "Authorization" in headers to
// test the no-credential path.
const testAuthToken = "test-dev-token"

// applyHeaders sets req's default Authorization header, then layers headers
// on top. A "" value deletes the header instead of setting it, so tests can
// exercise the missing-credential case.
func applyHeaders(req *http.Request, headers map[string]string) {
	req.Header.Set("Authorization", "Bearer "+testAuthToken)
	for k, v := range headers {
		if v == "" {
			req.Header.Del(k)
		} else {
			req.Header.Set(k, v)
		}
	}
}

func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
	t.Helper()
	req := httptest.NewRequest("GET", path, nil)
	applyHeaders(req, headers)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	var body map[string]any
	json.Unmarshal(rec.Body.Bytes(), &body)
	return rec, body
}

func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httptest.ResponseRecorder, map[string]any) {
	t.Helper()
	req := httptest.NewRequest("POST", path, strings.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	applyHeaders(req, nil)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	var body map[string]any
	json.Unmarshal(rec.Body.Bytes(), &body)
	return rec, body
}

func devConfig() config.Config {
	c := config.Default()
	c.APIEnv = "dev"
	c.APIToken = testAuthToken
	return c
}

func TestAPIEndToEnd(t *testing.T) {
	h := newTestHandler(t, devConfig())

	t.Run("healthz", func(t *testing.T) {
		rec, body := get(t, h, "/healthz", nil)
		if rec.Code != 200 || body["status"] != "ok" {
			t.Fatalf("healthz = %d %v", rec.Code, body)
		}
	})

	t.Run("list entities filtered by type", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/entities?type=service", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d: %v", rec.Code, body)
		}
		items := body["items"].([]any)
		if len(items) < 20 {
			t.Errorf("expected 20+ services, got %d", len(items))
		}
	})

	t.Run("type filter includes descendants via hierarchy", func(t *testing.T) {
		// machine is abstract; proxmox-host/workstation/standalone-server descend from it
		rec, body := get(t, h, "/api/v1/entities?type=machine", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d: %v", rec.Code, body)
		}
		items := body["items"].([]any)
		if len(items) < 5 {
			t.Errorf("expected 5+ machines (hubris, strong, vps, 2 workstations), got %d", len(items))
		}
	})

	t.Run("pagination", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/entities?limit=10", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d", rec.Code)
		}
		if len(body["items"].([]any)) != 10 {
			t.Fatalf("limit=10 returned %d", len(body["items"].([]any)))
		}
		cursor, _ := body["next_cursor"].(string)
		if cursor == "" {
			t.Fatal("expected next_cursor")
		}
		rec2, body2 := get(t, h, "/api/v1/entities?limit=10&cursor="+cursor, nil)
		if rec2.Code != 200 {
			t.Fatalf("page 2 status %d", rec2.Code)
		}
		first := body2["items"].([]any)[0].(map[string]any)["slug"].(string)
		if first <= cursor {
			t.Errorf("page 2 first slug %q not after cursor %q", first, cursor)
		}
	})

	t.Run("get entity by slug with etag", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/entities/host:hubris", nil)
		if rec.Code != 200 || body["slug"] != "host:hubris" {
			t.Fatalf("get by slug = %d %v", rec.Code, body["slug"])
		}
		if rec.Header().Get("ETag") == "" {
			t.Error("missing ETag header")
		}
		// and by UUID
		id := body["id"].(string)
		rec2, body2 := get(t, h, "/api/v1/entities/"+id, nil)
		if rec2.Code != 200 || body2["slug"] != "host:hubris" {
			t.Errorf("get by uuid = %d %v", rec2.Code, body2["slug"])
		}
	})

	t.Run("unknown entity is 404 problem+json", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/entities/host:nonexistent", nil)
		if rec.Code != 404 {
			t.Fatalf("status %d", rec.Code)
		}
		if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
			t.Errorf("content-type %q", ct)
		}
		if body["title"] != "not found" {
			t.Errorf("problem title %v", body["title"])
		}
	})

	t.Run("relations", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/entities/host:hubris/relations?rel_type=hosts&direction=out", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d", rec.Code)
		}
		items := body["items"].([]any)
		if len(items) < 10 {
			t.Errorf("hubris hosts %d guests, want 10+", len(items))
		}
	})

	t.Run("blast radius", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/entities/lxc:caddy/blast-radius?depth=2", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d", rec.Code)
		}
		if len(body["items"].([]any)) < 2 {
			t.Errorf("blast radius too small: %v", body["items"])
		}
	})

	t.Run("graph", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/graph?root=service:paperless&depth=2", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d", rec.Code)
		}
		if len(body["nodes"].([]any)) < 2 || len(body["edges"].([]any)) < 1 {
			t.Errorf("graph too small: %d nodes %d edges",
				len(body["nodes"].([]any)), len(body["edges"].([]any)))
		}
	})

	// Regression: rel_type is an optional array param (*[]string); when
	// omitted entirely (not an empty list), passing the nil pointer straight
	// through to pgx as a query arg panics because pgx can't infer the array
	// element type from a nil *[]string. root+depth alone must still work.
	t.Run("graph without rel_type", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/graph?root=host:hubris&depth=1", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d", rec.Code)
		}
		if len(body["nodes"].([]any)) < 2 {
			t.Errorf("graph too small: %d nodes", len(body["nodes"].([]any)))
		}
	})

	t.Run("ontology", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/ontology", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d", rec.Code)
		}
		if len(body["entity_types"].([]any)) != 59 {
			t.Errorf("entity_types = %d, want 59", len(body["entity_types"].([]any)))
		}
		if len(body["lifecycles"].([]any)) != 6 {
			t.Errorf("lifecycles = %d, want 6", len(body["lifecycles"].([]any)))
		}
	})

	t.Run("signals empty list", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/signals", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d: %v", rec.Code, body)
		}
	})

	t.Run("export returns real seeds", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/export", nil)
		if rec.Code != 200 {
			t.Fatalf("status %d", rec.Code)
		}
		if len(body["inventory"].(string)) < 1000 {
			t.Errorf("inventory export suspiciously small")
		}
	})

	t.Run("unimplemented endpoint is 501 problem+json", func(t *testing.T) {
		rec, body := get(t, h, "/api/v1/patterns", nil)
		if rec.Code != 501 {
			t.Fatalf("status %d, want 501", rec.Code)
		}
		if body["title"] != "not implemented" {
			t.Errorf("problem title %v", body["title"])
		}
	})
}

func TestAPIBearerAuth(t *testing.T) {
	cfg := devConfig()
	cfg.APIToken = "test-token-123"
	h := newTestHandler(t, cfg)

	// healthz stays open
	rec, _ := get(t, h, "/healthz", nil)
	if rec.Code != 200 {
		t.Errorf("healthz with auth enabled = %d, want 200", rec.Code)
	}

	// API requires the token
	rec, body := get(t, h, "/api/v1/entities", map[string]string{"Authorization": ""})
	if rec.Code != 401 {
		t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
	}
	rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer wrong"})
	if rec.Code != 401 {
		t.Errorf("wrong token = %d, want 401", rec.Code)
	}
	rec, _ = get(t, h, "/api/v1/entities", map[string]string{"Authorization": "Bearer test-token-123"})
	if rec.Code != 200 {
		t.Errorf("valid token = %d, want 200", rec.Code)
	}
}
