package api_test

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	"github.com/uopi/uopi/internal/access"
	"github.com/uopi/uopi/internal/api"
	"github.com/uopi/uopi/internal/audit"
	"github.com/uopi/uopi/internal/broker"
	"github.com/uopi/uopi/internal/confmgr"
	"github.com/uopi/uopi/internal/controllogic"
	"github.com/uopi/uopi/internal/datasource/stub"
	"github.com/uopi/uopi/internal/panelacl"
	"github.com/uopi/uopi/internal/storage"
)

// ── Test helpers ─────────────────────────────────────────────────────────────

func setup(t *testing.T) (*httptest.Server, func()) {
	t.Helper()

	ctx, cancel := context.WithCancel(context.Background())

	log := slog.New(slog.NewTextHandler(io.Discard, nil))
	brk := broker.New(ctx, log)
	ds := stub.New()
	if err := ds.Connect(ctx); err != nil {
		t.Fatal("stub connect:", err)
	}
	brk.Register(ds)

	dir := t.TempDir()
	store, err := storage.New(dir)
	if err != nil {
		t.Fatal("storage.New:", err)
	}
	acl, err := panelacl.New(dir)
	if err != nil {
		t.Fatal("panelacl.New:", err)
	}

	clStore, err := controllogic.NewStore(dir)
	if err != nil {
		t.Fatal("controllogic.NewStore:", err)
	}

	cfgStore, err := confmgr.New(dir)
	if err != nil {
		t.Fatal("confmgr.New:", err)
	}

	clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)

	mux := http.NewServeMux()
	api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")

	srv := httptest.NewServer(mux)
	return srv, func() {
		srv.Close()
		cancel()
	}
}

func get(t *testing.T, srv *httptest.Server, path string) *http.Response {
	t.Helper()
	resp, err := http.Get(srv.URL + path)
	if err != nil {
		t.Fatal("GET", path, err)
	}
	return resp
}

func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
	t.Helper()
	b, err := json.Marshal(body)
	if err != nil {
		t.Fatal("json.Marshal:", err)
	}
	resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b))
	if err != nil {
		t.Fatal("POST", path, err)
	}
	return resp
}

func postRaw(t *testing.T, srv *httptest.Server, path, ct string, body []byte) *http.Response {
	t.Helper()
	resp, err := http.Post(srv.URL+path, ct, bytes.NewReader(body))
	if err != nil {
		t.Fatal("POST", path, err)
	}
	return resp
}

func putRaw(t *testing.T, srv *httptest.Server, path, ct string, body []byte) *http.Response {
	t.Helper()
	req, err := http.NewRequest(http.MethodPut, srv.URL+path, bytes.NewReader(body))
	if err != nil {
		t.Fatal("NewRequest PUT:", err)
	}
	req.Header.Set("Content-Type", ct)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal("PUT", path, err)
	}
	return resp
}

func deleteReq(t *testing.T, srv *httptest.Server, path string) *http.Response {
	t.Helper()
	req, err := http.NewRequest(http.MethodDelete, srv.URL+path, nil)
	if err != nil {
		t.Fatal("NewRequest DELETE:", err)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal("DELETE", path, err)
	}
	return resp
}

func readJSON(t *testing.T, resp *http.Response, v any) {
	t.Helper()
	defer resp.Body.Close()
	if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
		t.Fatal("decode response:", err)
	}
}

func assertStatus(t *testing.T, resp *http.Response, want int) {
	t.Helper()
	if resp.StatusCode != want {
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		t.Fatalf("expected HTTP %d, got %d: %s", want, resp.StatusCode, bytes.TrimSpace(body))
	}
}

// ── /api/v1/datasources ───────────────────────────────────────────────────────

func TestListDataSources(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := get(t, srv, "/api/v1/datasources")
	assertStatus(t, resp, http.StatusOK)

	var sources []struct {
		Name   string `json:"name"`
		Status string `json:"status"`
	}
	readJSON(t, resp, &sources)

	if len(sources) == 0 {
		t.Fatal("expected at least one data source")
	}
	found := false
	for _, s := range sources {
		if s.Name == "stub" {
			found = true
			if s.Status != "connected" {
				t.Errorf("stub status = %q, want connected", s.Status)
			}
		}
	}
	if !found {
		t.Error("stub data source not in list")
	}
}

// ── /api/v1/signals ───────────────────────────────────────────────────────────

func TestListSignalsMissingDs(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := get(t, srv, "/api/v1/signals")
	assertStatus(t, resp, http.StatusBadRequest)
}

func TestListSignalsUnknownDs(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := get(t, srv, "/api/v1/signals?ds=nonexistent")
	assertStatus(t, resp, http.StatusNotFound)
}

func TestListSignalsStub(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := get(t, srv, "/api/v1/signals?ds=stub")
	assertStatus(t, resp, http.StatusOK)

	var signals []struct {
		Name string `json:"name"`
		Type string `json:"type"`
	}
	readJSON(t, resp, &signals)

	if len(signals) == 0 {
		t.Fatal("expected signals from stub data source")
	}
}

// ── /api/v1/signals/search ────────────────────────────────────────────────────

func TestSearchSignals(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := get(t, srv, "/api/v1/signals/search?q=sine")
	assertStatus(t, resp, http.StatusOK)

	var signals []struct {
		Name string `json:"name"`
	}
	readJSON(t, resp, &signals)

	for _, s := range signals {
		if !strings.Contains(s.Name, "sine") {
			t.Errorf("signal %q does not match query 'sine'", s.Name)
		}
	}
}

func TestSearchSignalsEmpty(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	// Empty query should return all signals.
	resp := get(t, srv, "/api/v1/signals/search")
	assertStatus(t, resp, http.StatusOK)

	var signals []any
	readJSON(t, resp, &signals)
	if len(signals) == 0 {
		t.Error("expected signals for empty query")
	}
}

// ── /api/v1/interfaces ────────────────────────────────────────────────────────

const sampleXML = `<interface id="" name="Test Panel" version="1" w="800" h="600"></interface>`

func TestInterfaceCRUD(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	// List — initially empty
	resp := get(t, srv, "/api/v1/interfaces")
	assertStatus(t, resp, http.StatusOK)
	var list []any
	readJSON(t, resp, &list)
	if len(list) != 0 {
		t.Fatalf("expected empty interface list, got %d", len(list))
	}

	// Create
	resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
	assertStatus(t, resp, http.StatusCreated)
	var created struct {
		ID string `json:"id"`
	}
	readJSON(t, resp, &created)
	if created.ID == "" {
		t.Fatal("expected non-empty ID from create")
	}

	// Get
	resp = get(t, srv, "/api/v1/interfaces/"+created.ID)
	assertStatus(t, resp, http.StatusOK)
	body, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	if !bytes.Contains(body, []byte("Test Panel")) {
		t.Errorf("GET response missing interface name: %s", body)
	}

	// Update
	updated := `<interface id="" name="Updated Panel" version="2" w="1024" h="768"></interface>`
	resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID, "application/xml", []byte(updated))
	assertStatus(t, resp, http.StatusNoContent)

	// Verify update
	resp = get(t, srv, "/api/v1/interfaces/"+created.ID)
	assertStatus(t, resp, http.StatusOK)
	body, _ = io.ReadAll(resp.Body)
	resp.Body.Close()
	if !bytes.Contains(body, []byte("Updated Panel")) {
		t.Errorf("GET after update missing new name: %s", body)
	}

	// List — now one entry
	resp = get(t, srv, "/api/v1/interfaces")
	assertStatus(t, resp, http.StatusOK)
	readJSON(t, resp, &list)
	if len(list) != 1 {
		t.Fatalf("expected 1 interface in list, got %d", len(list))
	}

	// Clone
	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/interfaces/"+created.ID+"/clone", nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal("clone:", err)
	}
	assertStatus(t, resp, http.StatusCreated)
	var cloned struct {
		ID string `json:"id"`
	}
	readJSON(t, resp, &cloned)
	if cloned.ID == created.ID {
		t.Error("clone produced same ID as original")
	}

	// Delete original
	resp = deleteReq(t, srv, "/api/v1/interfaces/"+created.ID)
	assertStatus(t, resp, http.StatusNoContent)

	// Get after delete → 404
	resp = get(t, srv, "/api/v1/interfaces/"+created.ID)
	assertStatus(t, resp, http.StatusNotFound)

	// Delete non-existent → 404
	resp = deleteReq(t, srv, "/api/v1/interfaces/"+created.ID)
	assertStatus(t, resp, http.StatusNotFound)
}

func TestInterfaceInvalidXML(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte("not xml at all"))
	assertStatus(t, resp, http.StatusBadRequest)
}

func TestInterfacePathTraversal(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	// Attempt to read a file outside the storage dir via path traversal in the ID.
	resp := get(t, srv, "/api/v1/interfaces/../etc/passwd")
	// Should be 404 (invalid ID), not 200 or 500.
	if resp.StatusCode == http.StatusOK {
		t.Error("path traversal succeeded — expected 404")
	}
}

// ── Synthetic (disabled — synth is nil) ──────────────────────────────────────

func TestSyntheticDisabled(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := get(t, srv, "/api/v1/synthetic")
	assertStatus(t, resp, http.StatusOK)
	var out []any
	readJSON(t, resp, &out)
	if len(out) != 0 {
		t.Errorf("expected empty list when synthetic is nil, got %v", out)
	}
}

func TestSyntheticCreateDisabled(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := postJSON(t, srv, "/api/v1/synthetic", map[string]any{"name": "test"})
	assertStatus(t, resp, http.StatusServiceUnavailable)
}

// ── Channel Finder (not configured) ──────────────────────────────────────────

func TestChannelFinderNotConfigured(t *testing.T) {
	srv, teardown := setup(t)
	defer teardown()

	resp := get(t, srv, "/api/v1/channel-finder?q=test")
	assertStatus(t, resp, http.StatusNotImplemented)
}

// ── /healthz ──────────────────────────────────────────────────────────────────

func TestHealthz(t *testing.T) {
	// healthz is registered in server.go, not api.go, so test it directly.
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/healthz")
	if err != nil {
		t.Fatal(err)
	}
	assertStatus(t, resp, http.StatusOK)
	body, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	if string(body) != "ok" {
		t.Errorf("healthz body = %q, want ok", body)
	}
}

// ── Storage ID validation ─────────────────────────────────────────────────────

func TestStorageValidateID(t *testing.T) {
	dir := t.TempDir()
	store, err := storage.New(dir)
	if err != nil {
		t.Fatal(err)
	}

	// Create a real interface first to get a valid ID.
	id, err := store.Create([]byte(sampleXML), "")
	if err != nil {
		t.Fatal("create:", err)
	}

	// Valid ID works.
	data, err := store.Get(id)
	if err != nil {
		t.Fatalf("Get(%q): %v", id, err)
	}
	if len(data) == 0 {
		t.Error("expected data, got empty")
	}

	// Malicious IDs must be rejected.
	malicious := []string{"../etc/passwd", "../../secret", "a/b", "a\x00b", ""}
	for _, bad := range malicious {
		if _, err := store.Get(bad); err == nil {
			t.Errorf("Get(%q) should have failed, got nil error", bad)
		}
		if err := store.Update(bad, []byte(sampleXML), ""); err == nil {
			t.Errorf("Update(%q) should have failed", bad)
		}
		if err := store.Delete(bad); err == nil {
			t.Errorf("Delete(%q) should have failed", bad)
		}
	}
}

// ── /api/v1/admin ─────────────────────────────────────────────────────────────

// adminSetup builds a server whose mux injects the user named by the
// "X-Test-User" header into the request context (mirroring the real access
// middleware), so admin-gating can be exercised. The policy restricts admin to
// the "ops" group, of which "alice" is a member.
func adminSetup(t *testing.T) (*httptest.Server, func()) {
	t.Helper()
	ctx, cancel := context.WithCancel(context.Background())
	log := slog.New(slog.NewTextHandler(io.Discard, nil))
	brk := broker.New(ctx, log)
	ds := stub.New()
	if err := ds.Connect(ctx); err != nil {
		t.Fatal("stub connect:", err)
	}
	brk.Register(ds)

	dir := t.TempDir()
	store, _ := storage.New(dir)
	acl, _ := panelacl.New(dir)
	clStore, _ := controllogic.NewStore(dir)
	cfgStore, _ := confmgr.New(dir)
	clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)

	// "alice" is an admin (via the ops group); everyone else is a viewer.
	policy := access.New("", []access.GroupSpec{
		{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
	})
	if err := policy.EnablePersistence(dir); err != nil {
		t.Fatal("EnablePersistence:", err)
	}

	inner := http.NewServeMux()
	api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1")

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if u := r.Header.Get("X-Test-User"); u != "" {
			r = r.WithContext(access.WithUser(r.Context(), u))
		}
		inner.ServeHTTP(w, r)
	})

	srv := httptest.NewServer(mux)
	return srv, func() { srv.Close(); cancel() }
}

func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
	t.Helper()
	var rdr io.Reader
	if body != nil {
		rdr = bytes.NewReader(body)
	}
	req, err := http.NewRequest(method, srv.URL+path, rdr)
	if err != nil {
		t.Fatal("NewRequest:", err)
	}
	if user != "" {
		req.Header.Set("X-Test-User", user)
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(method, path, err)
	}
	return resp
}

func TestAdminForbiddenForNonAdmin(t *testing.T) {
	srv, teardown := adminSetup(t)
	defer teardown()

	// "bob" is only a viewer → 403 on every admin route.
	assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
	assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
	assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
		[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
}

func TestAdminUserAndGroupMutations(t *testing.T) {
	srv, teardown := adminSetup(t)
	defer teardown()

	// alice (admin) assigns carol an auditor role in a new "team" group.
	resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
		[]byte(`{"roles":{"team":"auditor"}}`))
	assertStatus(t, resp, http.StatusOK)

	var snap access.AccessSnapshot
	readJSON(t, resp, &snap)
	var carol *access.UserInfo
	for i := range snap.Users {
		if snap.Users[i].Name == "carol" {
			carol = &snap.Users[i]
		}
	}
	if carol == nil {
		t.Fatal("carol missing from snapshot")
	}
	if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
		t.Errorf("carol = %+v, want auditor in team", carol)
	}

	// Create (with parent), rename, and delete a group.
	assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
		[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
	resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
		[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
	assertStatus(t, resp, http.StatusOK)
	readJSON(t, resp, &snap)
	if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
		t.Errorf("groups after rename = %+v", snap.Groups)
	}

	assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
	assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
	// The built-in public group cannot be deleted.
	assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
}

func hasGroup(s access.AccessSnapshot, name string) bool {
	for _, g := range s.Groups {
		if g.Name == name {
			return true
		}
	}
	return false
}

func TestAdminStats(t *testing.T) {
	srv, teardown := adminSetup(t)
	defer teardown()

	resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
	assertStatus(t, resp, http.StatusOK)
	var stats map[string]any
	readJSON(t, resp, &stats)
	for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
		if _, ok := stats[k]; !ok {
			t.Errorf("stats missing key %q", k)
		}
	}
}

// ── Ensure os is used (blank import guard) ─────────────────────────────────────

var _ = os.DevNull
