package api

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"crypto-miner-server/internal/alerts"
	"crypto-miner-server/internal/db"
	"crypto-miner-server/internal/models"
	"crypto-miner-server/internal/pool"

	"github.com/go-chi/chi/v5"
	"github.com/gorilla/websocket"
)

// Numeric constants from fleet_handler.go (guard against silent drift).
const (
	fleetEarningsCacheTTL   = 5 * time.Minute
	fleetXMRPriceTTL        = 10 * time.Minute
	fleetNetworkHashrate    = 3_000_000_000.0
	fleetDailyEmissionXMR   = 432.0
	fleetPiconeroPerXMR     = 1e12
	fleetGetLogTailLines    = 300
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
	return f(req)
}

func setMockHTTPTransport(t *testing.T, fn roundTripFunc) {
	t.Helper()
	orig := http.DefaultTransport
	http.DefaultTransport = fn
	t.Cleanup(func() { http.DefaultTransport = orig })
}

func resetXMRPriceCache(t *testing.T, fh *FleetHandler) {
	t.Helper()
	fh.xmrPriceMu.Lock()
	fh.xmrPriceCache = nil
	fh.xmrPriceMu.Unlock()
	t.Cleanup(func() {
		fh.xmrPriceMu.Lock()
		fh.xmrPriceCache = nil
		fh.xmrPriceMu.Unlock()
	})
}

func newTestFleetHandler(t *testing.T) (*FleetHandler, *db.Database, *WSHub, *AIHandler) {
	t.Helper()
	database, err := db.New(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = database.Close() })
	ws := NewWSHub(database)
	ai := NewAIHandler(database)
	fh := NewFleetHandler(database, ws, ai, nil, nil, pool.Config{})
	return fh, database, ws, ai
}

func fleetChiRoute(method, pattern string, handler http.HandlerFunc) http.Handler {
	r := chi.NewRouter()
	switch method {
	case http.MethodGet:
		r.Get(pattern, handler)
	case http.MethodPost:
		r.Post(pattern, handler)
	case http.MethodPut:
		r.Put(pattern, handler)
	default:
		panic("unsupported method " + method)
	}
	return r
}

func connectTestAgent(t *testing.T, hub *WSHub, agentID string) *websocket.Conn {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS))
	t.Cleanup(srv.Close)

	wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
	conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
	if err != nil {
		t.Fatalf("dial agent ws: %v", err)
	}
	t.Cleanup(func() { _ = conn.Close() })

	authPayload, _ := json.Marshal(map[string]interface{}{
		"agent_id": agentID,
		"hostname": "test-host",
		"version":  "1.0",
	})
	if err := conn.WriteJSON(Message{Type: "auth", Payload: authPayload}); err != nil {
		t.Fatalf("send auth: %v", err)
	}

	var resp Message
	if err := conn.ReadJSON(&resp); err != nil {
		t.Fatalf("read auth_response: %v", err)
	}
	if resp.Type != "auth_response" {
		t.Fatalf("expected auth_response, got %q", resp.Type)
	}

	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		if hub.isAgentConnected(agentID) {
			return conn
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatal("agent not connected after auth")
	return nil
}

func TestFleetConstants(t *testing.T) {
	if fleetEarningsCacheTTL != earningsCacheTTL ||
		fleetXMRPriceTTL != xmrPriceTTL ||
		fleetNetworkHashrate != 3_000_000_000.0 ||
		fleetDailyEmissionXMR != 432.0 {
		t.Fatal("fleet_handler constants drifted from documented values")
	}
	out := EstimateXMRPerDay(fleetNetworkHashrate)
	if out["network_hashrate"].(float64) != fleetNetworkHashrate {
		t.Fatalf("network_hashrate want %v got %v", fleetNetworkHashrate, out["network_hashrate"])
	}
	xmr, ok := out["xmr_per_day"].(float64)
	if !ok || xmr != fleetDailyEmissionXMR {
		t.Fatalf("at network hashrate want xmr_per_day=%v got %v", fleetDailyEmissionXMR, xmr)
	}
}

func TestFleetNewFleetHandler(t *testing.T) {
	fh, database, ws, ai := newTestFleetHandler(t)
	if fh.db != database || fh.ws != ws || fh.ai != ai {
		t.Fatal("NewFleetHandler did not wire dependencies")
	}
	if fh.earningsCache != nil {
		t.Fatal("expected nil earnings cache at init")
	}
}

func TestFleetGetAlertsNilEvaluator(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	var body []alerts.AlertEvent
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
		t.Fatalf("expected empty alerts array, got %s", rec.Body.String())
	}
}

func TestFleetGetAlertsWithEvaluator(t *testing.T) {
	database, err := db.New(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = database.Close() })

	agent := &models.Agent{
		ID:       "alert-agent",
		Name:     "offline-rig",
		Status:   "offline",
		LastSeen: time.Now().Add(-30 * time.Minute),
	}
	if err := database.UpsertAgent(agent); err != nil {
		t.Fatal(err)
	}

	evaluator := alerts.NewEvaluator(database, func() alerts.Thresholds {
		return alerts.Thresholds{OfflineMinutes: 5}
	}, func() alerts.NotifyConfig { return alerts.NotifyConfig{} }, nil)
	evaluator.RunOnce()

	fh := NewFleetHandler(database, NewWSHub(database), NewAIHandler(database), nil, evaluator, pool.Config{})
	rec := httptest.NewRecorder()
	fh.GetAlerts(rec, httptest.NewRequest(http.MethodGet, "/alerts", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	var body []alerts.AlertEvent
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if len(body) != 1 || body[0].Type != "offline" {
		t.Fatalf("expected offline alert, got %+v", body)
	}
}

func TestFleetGetPoolStatusNilManager(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	if rec.Body.String() != "[]\n" && rec.Body.String() != "[]" {
		var body []pool.PoolStatus
		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
			t.Fatalf("expected empty pool status, got %s", rec.Body.String())
		}
	}
}

func TestFleetGetPoolStatusWithManager(t *testing.T) {
	pm := pool.NewManager(nil, nil)
	fh, _, _, _ := newTestFleetHandler(t)
	fh.pools = pm

	rec := httptest.NewRecorder()
	fh.GetPoolStatus(rec, httptest.NewRequest(http.MethodGet, "/pools", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	var body []pool.PoolStatus
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if len(body) != 0 {
		t.Fatalf("expected empty list from manager with no pools, got %d", len(body))
	}
}

func TestFleetGetAIActivityNilHandler(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	var body []AIActivityEntry
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body) != 0 {
		t.Fatalf("expected empty activity, got %s", rec.Body.String())
	}
}

func TestFleetGetAIActivityWithEntries(t *testing.T) {
	fh, _, _, ai := newTestFleetHandler(t)
	ai.recordActivity(AIActivityEntry{
		AgentID:    "agent-x",
		LastAction: "decide",
		LastTool:   "ok",
	})

	rec := httptest.NewRecorder()
	fh.GetAIActivity(rec, httptest.NewRequest(http.MethodGet, "/ai/activity", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	var body []AIActivityEntry
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if len(body) != 1 || body[0].AgentID != "agent-x" {
		t.Fatalf("unexpected activity: %+v", body)
	}
}

func TestFleetGetXMRPriceCacheHit(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	resetXMRPriceCache(t, fh)
	fetchedAt := time.Now().Add(-2 * time.Minute)
	fh.xmrPriceMu.Lock()
	fh.xmrPriceCache = &xmrPriceEntry{USD: 165.5, fetchedAt: fetchedAt}
	fh.xmrPriceMu.Unlock()
	rec := httptest.NewRecorder()
	fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["usd"].(float64) != 165.5 || body["source"] != "coingecko" {
		t.Fatalf("unexpected body: %v", body)
	}
}

func TestFleetGetXMRPriceFetchSuccess(t *testing.T) {
	setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
		if !strings.Contains(req.URL.Host, "coingecko.com") {
			t.Fatalf("unexpected host %s", req.URL.Host)
		}
		rec := httptest.NewRecorder()
		rec.Header().Set("Content-Type", "application/json")
		_, _ = rec.Write([]byte(`{"monero":{"usd":200.25}}`))
		return rec.Result(), nil
	})

	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["usd"].(float64) != 200.25 {
		t.Fatalf("unexpected usd: %v", body["usd"])
	}
}

func TestFleetGetXMRPriceFetchNetworkError(t *testing.T) {
	setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
		return nil, errors.New("network down")
	})

	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("expected 503, got %d body %s", rec.Code, rec.Body.String())
	}
}

func TestFleetGetXMRPriceParseError(t *testing.T) {
	setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
		rec := httptest.NewRecorder()
		rec.WriteHeader(http.StatusOK)
		_, _ = rec.Write([]byte(`not-json`))
		return rec.Result(), nil
	})

	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	fh.GetXMRPrice(rec, httptest.NewRequest(http.MethodGet, "/market/xmr", nil))
	if rec.Code != http.StatusBadGateway {
		t.Fatalf("expected 502, got %d body %s", rec.Code, rec.Body.String())
	}
}

func TestFleetEstimateXMRPerDay(t *testing.T) {
	zero := EstimateXMRPerDay(0)
	if zero["xmr_per_day"].(float64) != 0 {
		t.Fatalf("zero hashrate should yield 0 xmr, got %v", zero["xmr_per_day"])
	}
	if zero["usd_per_day"] != nil {
		t.Fatal("usd_per_day should be nil without price feed")
	}

	atNetwork := EstimateXMRPerDay(fleetNetworkHashrate)
	if atNetwork["xmr_per_day"].(float64) != fleetDailyEmissionXMR {
		t.Fatalf("full network share want %v got %v", fleetDailyEmissionXMR, atNetwork["xmr_per_day"])
	}

	half := EstimateXMRPerDay(fleetNetworkHashrate / 2)
	wantHalf := fleetDailyEmissionXMR / 2
	if half["xmr_per_day"].(float64) != wantHalf {
		t.Fatalf("half network want %v got %v", wantHalf, half["xmr_per_day"])
	}
}

func TestFleetParseFloatQuery(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/?hashrate=123.45&bad=abc", nil)
	if got := parseFloatQuery(req, "hashrate", 0); got != 123.45 {
		t.Fatalf("hashrate want 123.45 got %v", got)
	}
	if got := parseFloatQuery(req, "missing", 9); got != 9 {
		t.Fatalf("missing key want default 9 got %v", got)
	}
	if got := parseFloatQuery(req, "bad", 7); got != 7 {
		t.Fatalf("invalid float want default 7 got %v", got)
	}
}

func TestFleetGetEarningsEstimateOnly(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000000", nil)
	fh.GetEarnings(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["source"] != nil {
		t.Fatalf("expected estimate-only response, got source=%v", body["source"])
	}
	if body["hashrate"].(float64) != 1_000_000 {
		t.Fatalf("hashrate not parsed: %v", body["hashrate"])
	}
}

func TestFleetGetEarningsWalletFromBuild(t *testing.T) {
	fh, database, _, _ := newTestFleetHandler(t)
	wallet := "48buildwalletaddress0000000000000000000000000000000000000000000000000000000000"
	if err := database.InsertBuild(&models.BuildRecord{
		ID:        "build-earn-1",
		Wallet:    wallet,
		CreatedAt: time.Now(),
	}); err != nil {
		t.Fatal(err)
	}

	setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
		if !strings.Contains(req.URL.Host, "supportxmr.com") {
			return nil, errors.New("unexpected host")
		}
		rec := httptest.NewRecorder()
		rec.Header().Set("Content-Type", "application/json")
		_, _ = rec.Write([]byte(`{
			"amtDue": 1000000000000,
			"amtPaid": 2000000000000,
			"totalHashes": 999,
			"hashRate": 5000,
			"lastPaymentTs": 1609459200,
			"lastPayment": 500000000000
		}`))
		return rec.Result(), nil
	})

	rec := httptest.NewRecorder()
	fh.GetEarnings(rec, httptest.NewRequest(http.MethodGet, "/earnings?hashrate=1000", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["source"] != "pool_api" {
		t.Fatalf("expected pool_api source, got %v", body["source"])
	}
	if body["pending_xmr"].(float64) != 1.0 {
		t.Fatalf("pending_xmr want 1.0 got %v", body["pending_xmr"])
	}
	if body["paid_xmr"].(float64) != 2.0 {
		t.Fatalf("paid_xmr want 2.0 got %v", body["paid_xmr"])
	}
	if body["last_payment_xmr"].(float64) != 0.5 {
		t.Fatalf("last_payment_xmr want 0.5 got %v", body["last_payment_xmr"])
	}
}

func TestFleetGetEarningsPoolAPIFallback(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
		return nil, errors.New("pool unreachable")
	})

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=48fallback&hashrate=2000000", nil)
	fh.GetEarnings(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["source"] != nil {
		t.Fatalf("expected estimate fallback without source, got %v", body["source"])
	}
	if body["hashrate"].(float64) != 2_000_000 {
		t.Fatalf("hashrate %v", body["hashrate"])
	}
}

func TestFleetGetEarningsEstimateDelegates(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	req := httptest.NewRequest(http.MethodGet, "/earnings/estimate?hashrate=500000", nil)

	recDirect := httptest.NewRecorder()
	fh.GetEarnings(recDirect, req)

	recDelegate := httptest.NewRecorder()
	fh.GetEarningsEstimate(recDelegate, req)

	if recDirect.Body.String() != recDelegate.Body.String() {
		t.Fatalf("GetEarningsEstimate should delegate to GetEarnings\ndirect: %s\ndelegate: %s",
			recDirect.Body.String(), recDelegate.Body.String())
	}
}

func TestFleetFetchPoolEarningsCache(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	cached := map[string]interface{}{"pending_xmr": 3.14}
	fh.earningsMu.Lock()
	fh.earningsCache = map[string]*poolEarningsCache{
		"cached-wallet": {data: cached, fetchedAt: time.Now()},
	}
	fh.earningsMu.Unlock()

	got, err := fh.fetchPoolEarnings("cached-wallet")
	if err != nil {
		t.Fatal(err)
	}
	if got["pending_xmr"].(float64) != 3.14 {
		t.Fatalf("cache miss or wrong data: %v", got)
	}
}

func TestFleetFetchPoolEarningsHTTPError(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
		rec := httptest.NewRecorder()
		rec.WriteHeader(http.StatusNotFound)
		return rec.Result(), nil
	})
	if _, err := fh.fetchPoolEarnings("missing-wallet"); err == nil {
		t.Fatal("expected error for non-200 pool response")
	}
}

func TestFleetGetAgentLogNilWS(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	fh.ws = nil
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/agents/a1/log", nil)
	fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("expected 503, got %d", rec.Code)
	}
}

func TestFleetGetAgentLogContentAndRefresh(t *testing.T) {
	fh, _, ws, _ := newTestFleetHandler(t)
	ws.mu.Lock()
	ws.agentLogs["agent-log-1"] = "line1\nline2"
	ws.mu.Unlock()

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/agents/agent-log-1/log?refresh=1", nil)
	fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["content"] != "line1\nline2" {
		t.Fatalf("content %v", body["content"])
	}
}

func TestFleetPostAgentCommandErrors(t *testing.T) {
	fh, _, ws, _ := newTestFleetHandler(t)

	t.Run("nil ws", func(t *testing.T) {
		bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{"action":"pause"}`))
		fleetChiRoute(http.MethodPost, "/agents/{id}/command", bad.PostAgentCommand).ServeHTTP(rec, req)
		if rec.Code != http.StatusServiceUnavailable {
			t.Fatalf("expected 503, got %d", rec.Code)
		}
	})

	t.Run("invalid json", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{`))
		fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
		if rec.Code != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", rec.Code)
		}
	})

	t.Run("missing action", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/a1/command", strings.NewReader(`{}`))
		fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
		if rec.Code != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", rec.Code)
		}
	})

	t.Run("agent not connected", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/offline-agent/command",
			strings.NewReader(`{"action":"pause"}`))
		fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
		if rec.Code != http.StatusOK {
			t.Fatalf("expected 200, got %d body %s", rec.Code, rec.Body.String())
		}
		var body map[string]interface{}
		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
			t.Fatal(err)
		}
		if body["success"] != false || body["error"] != "agent not connected" {
			t.Fatalf("unexpected body: %v", body)
		}
	})

	t.Run("broadcast all no agents", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/all/command",
			strings.NewReader(`{"action":"pause"}`))
		fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
		if rec.Code != http.StatusOK {
			t.Fatalf("expected 200, got %d", rec.Code)
		}
		var body map[string]interface{}
		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
			t.Fatal(err)
		}
		if body["success"] != false || body["error"] != "no connected agents" {
			t.Fatalf("unexpected body: %v", body)
		}
	})

	_ = ws
}

func TestFleetPostAgentCommandSuccess(t *testing.T) {
	fh, _, ws, _ := newTestFleetHandler(t)
	agentID := "cmd-agent-1"
	conn := connectTestAgent(t, ws, agentID)

	rec := httptest.NewRecorder()
	body := `{"action":"get_log","tail_lines":50,"command":"whoami","path":"C:\\","data":"x"}`
	req := httptest.NewRequest(http.MethodPost, "/agents/"+agentID+"/command", strings.NewReader(body))
	fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
	}

	var cmd Message
	if err := conn.ReadJSON(&cmd); err != nil {
		t.Fatalf("read command: %v", err)
	}
	if cmd.Type != "command" {
		t.Fatalf("expected command message, got %q", cmd.Type)
	}
	var payload map[string]interface{}
	if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
		t.Fatal(err)
	}
	if payload["action"] != "get_log" || payload["tail_lines"].(float64) != 50 {
		t.Fatalf("unexpected payload: %v", payload)
	}
}

func TestFleetPostAgentCommandBroadcastAll(t *testing.T) {
	fh, _, ws, _ := newTestFleetHandler(t)
	conn := connectTestAgent(t, ws, "broadcast-agent")

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/agents/all/command",
		strings.NewReader(`{"action":"resume"}`))
	fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}

	var cmd Message
	if err := conn.ReadJSON(&cmd); err != nil {
		t.Fatalf("read broadcast command: %v", err)
	}
	if cmd.Type != "command" {
		t.Fatalf("expected command, got %q", cmd.Type)
	}
}

func TestFleetPutAgentMetaErrors(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)

	t.Run("not found", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPut, "/agents/missing/meta",
			strings.NewReader(`{"notes":"n","tags":["a"]}`))
		fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
		if rec.Code != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", rec.Code)
		}
	})

	t.Run("invalid body", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPut, "/agents/a1/meta", errReader{})
		fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
		if rec.Code != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", rec.Code)
		}
	})

	t.Run("empty id", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(`{"notes":"n"}`))
		rctx := chi.NewRouteContext()
		rctx.URLParams.Add("id", "")
		req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
		fh.PutAgentMeta(rec, req)
		if rec.Code != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", rec.Code)
		}
	})
}

func TestFleetPutAgentMetaSuccess(t *testing.T) {
	fh, database, _, _ := newTestFleetHandler(t)
	agent := &models.Agent{ID: "meta-agent", Name: "rig", Status: "offline", LastSeen: time.Now()}
	if err := database.UpsertAgent(agent); err != nil {
		t.Fatal(err)
	}

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPut, "/agents/meta-agent/meta",
		strings.NewReader(`{"notes":"lab box","tags":["gpu","win"]}`))
	fleetChiRoute(http.MethodPut, "/agents/{id}/meta", fh.PutAgentMeta).ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["success"] != true {
		t.Fatalf("unexpected body: %v", body)
	}
	agentObj := body["agent"].(map[string]interface{})
	if agentObj["notes"] != "lab box" {
		t.Fatalf("notes not saved: %v", agentObj["notes"])
	}
}

func TestFleetPostBulkCommandErrors(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)

	t.Run("nil ws", func(t *testing.T) {
		bad := NewFleetHandler(fh.db, nil, fh.ai, fh.pools, fh.alerts, fh.defaultPool)
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
			strings.NewReader(`{"agent_ids":["a"],"action":"pause"}`))
		bad.PostBulkCommand(rec, req)
		if rec.Code != http.StatusServiceUnavailable {
			t.Fatalf("expected 503, got %d", rec.Code)
		}
	})

	t.Run("invalid body", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(`{`))
		fh.PostBulkCommand(rec, req)
		if rec.Code != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", rec.Code)
		}
	})

	t.Run("missing action", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
			strings.NewReader(`{"agent_ids":["a"]}`))
		fh.PostBulkCommand(rec, req)
		if rec.Code != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", rec.Code)
		}
	})

	t.Run("missing agent ids", func(t *testing.T) {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
			strings.NewReader(`{"agent_ids":[],"action":"pause"}`))
		fh.PostBulkCommand(rec, req)
		if rec.Code != http.StatusOK {
			t.Fatalf("expected 200, got %d", rec.Code)
		}
		var body map[string]interface{}
		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
			t.Fatal(err)
		}
		if body["success"] != false {
			t.Fatalf("expected success false, got %v", body)
		}
	})
}

func TestFleetPostBulkCommandPartialSuccess(t *testing.T) {
	fh, _, ws, _ := newTestFleetHandler(t)
	onlineID := "bulk-online"
	connectTestAgent(t, ws, onlineID)

	rec := httptest.NewRecorder()
	payload := `{"agent_ids":["` + onlineID + `","offline-one"],"action":"pause","command":"tasklist"}`
	req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", strings.NewReader(payload))
	fh.PostBulkCommand(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
	}
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["success"] != true {
		t.Fatalf("expected partial success true, got %v", body)
	}
	if body["sent"].(float64) != 1 || body["failed"].(float64) != 1 {
		t.Fatalf("sent/failed counts: %v", body)
	}
}

func TestFleetMinHelper(t *testing.T) {
	if min(3, 5) != 3 || min(5, 3) != 3 || min(4, 4) != 4 {
		t.Fatal("min helper wrong")
	}
}

// errReader reuse from ai_handler_test.go for invalid JSON bodies.
var _ io.ReadCloser = errReader{}

func TestFleetGetLogRefreshUsesTailConstant(t *testing.T) {
	fh, _, ws, _ := newTestFleetHandler(t)
	agentID := "tail-agent"
	conn := connectTestAgent(t, ws, agentID)

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/agents/"+agentID+"/log?refresh=1", nil)
	fleetChiRoute(http.MethodGet, "/agents/{id}/log", fh.GetAgentLog).ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}

	var cmd Message
	if err := conn.ReadJSON(&cmd); err != nil {
		t.Fatalf("read get_log command: %v", err)
	}
	var payload map[string]interface{}
	if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
		t.Fatal(err)
	}
	if payload["action"] != "get_log" {
		t.Fatalf("action %v", payload["action"])
	}
	if int(payload["tail_lines"].(float64)) != fleetGetLogTailLines {
		t.Fatalf("tail_lines want %d got %v", fleetGetLogTailLines, payload["tail_lines"])
	}
}

func TestFleetFetchPoolEarningsInvalidJSON(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	setMockHTTPTransport(t, func(req *http.Request) (*http.Response, error) {
		rec := httptest.NewRecorder()
		rec.WriteHeader(http.StatusOK)
		_, _ = rec.Write([]byte(`{`))
		return rec.Result(), nil
	})
	if _, err := fh.fetchPoolEarnings("bad-json-wallet"); err == nil {
		t.Fatal("expected unmarshal error")
	}
}

func TestFleetGetEarningsWithWalletQueryParam(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	fh.earningsMu.Lock()
	fh.earningsCache = map[string]*poolEarningsCache{
		"query-wallet": {
			data:      map[string]interface{}{"paid_xmr": 9.99},
			fetchedAt: time.Now(),
		},
	}
	fh.earningsMu.Unlock()

	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/earnings?wallet=query-wallet", nil)
	fh.GetEarnings(rec, req)
	var body map[string]interface{}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatal(err)
	}
	if body["paid_xmr"].(float64) != 9.99 || body["source"] != "pool_api" {
		t.Fatalf("unexpected merged body: %v", body)
	}
}

func TestFleetPostAgentCommandEmptyAgentID(t *testing.T) {
	fh, _, _, _ := newTestFleetHandler(t)
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"action":"pause"}`))
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", "")
	req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
	fh.PostAgentCommand(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("expected 400 for empty id, got %d", rec.Code)
	}
}
