package agent

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

	"github.com/gin-gonic/gin"

	"security-center-backend/internal/config"
	"security-center-backend/internal/model"
	"security-center-backend/internal/repository"
	"security-center-backend/internal/service/jwt"
)

// spyAPIKeyRepo implements repository.APIKeyRepository with call recording, so
// tests can assert which lookup path (owner-scoped vs admin fallback) the
// handler took and which arguments were passed.
type spyAPIKeyRepo struct {
	listByOwnerKeys   []*model.APIKey
	listByOwnerCalled bool
	listByOwnerUserID uint64

	listAllKeys   []*model.APIKey
	listAllCalled bool
	listAllFilter repository.ListAPIKeysFilter

	findByIDKey    *model.APIKey
	findByIDCalled bool

	findByIDForOwnerKey    *model.APIKey
	findByIDForOwnerCalled bool
	findByIDForOwnerArgs   [2]uint64
}

func (s *spyAPIKeyRepo) Create(context.Context, *model.APIKey) error { return nil }
func (s *spyAPIKeyRepo) FindByHash(context.Context, string) (*model.APIKey, error) {
	return nil, nil
}
func (s *spyAPIKeyRepo) TouchLastUsed(context.Context, uint64) error  { return nil }
func (s *spyAPIKeyRepo) Revoke(context.Context, uint64, uint64) error { return nil }

func (s *spyAPIKeyRepo) ListByOwner(_ context.Context, ownerUserID uint64) ([]*model.APIKey, error) {
	s.listByOwnerCalled = true
	s.listByOwnerUserID = ownerUserID
	return s.listByOwnerKeys, nil
}

func (s *spyAPIKeyRepo) ListAll(_ context.Context, filter repository.ListAPIKeysFilter) ([]*model.APIKey, error) {
	s.listAllCalled = true
	s.listAllFilter = filter
	return s.listAllKeys, nil
}

func (s *spyAPIKeyRepo) FindByID(_ context.Context, id uint64) (*model.APIKey, error) {
	s.findByIDCalled = true
	return s.findByIDKey, nil
}

func (s *spyAPIKeyRepo) FindByIDForOwner(_ context.Context, id, ownerUserID uint64) (*model.APIKey, error) {
	s.findByIDForOwnerCalled = true
	s.findByIDForOwnerArgs = [2]uint64{id, ownerUserID}
	return s.findByIDForOwnerKey, nil
}

// newListGetTestEngine mounts the routes behind a middleware that injects the
// given claims, simulating the authenticated context the JWT middleware would
// produce (including the admin role for the fallback paths).
func newListGetTestEngine(repo repository.APIKeyRepository, claims *jwt.Claims) *gin.Engine {
	gin.SetMode(gin.TestMode)
	engine := gin.New()
	h := New(repo, config.AgentConfig{Enabled: true, KeyPrefix: "sk-agent-"})
	group := engine.Group("/api/v1/agent/api-keys")
	group.Use(func(c *gin.Context) {
		c.Set("current_user", claims)
		c.Next()
	})
	h.RegisterRoutes(group)
	return engine
}

// doListGet issues a GET and returns the envelope body code plus raw data.
func doListGet(t *testing.T, engine *gin.Engine, path string) (int, json.RawMessage) {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, path, nil)
	w := httptest.NewRecorder()
	engine.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("http status = %d, want 200 (envelope convention); body=%s", w.Code, w.Body.String())
	}
	var body struct {
		Code int             `json:"code"`
		Data json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode API response: %v; body=%s", err, w.Body.String())
	}
	return body.Code, body.Data
}

func decodeListData(t *testing.T, data json.RawMessage) []*APIKeyResponse {
	t.Helper()
	var items []*APIKeyResponse
	if err := json.Unmarshal(data, &items); err != nil {
		t.Fatalf("decode list data: %v", err)
	}
	return items
}

func decodeGetData(t *testing.T, data json.RawMessage) *APIKeyResponse {
	t.Helper()
	var item APIKeyResponse
	if err := json.Unmarshal(data, &item); err != nil {
		t.Fatalf("decode get data: %v", err)
	}
	return &item
}

func TestListRegularUserGetsOnlyOwnKeys(t *testing.T) {
	repo := &spyAPIKeyRepo{listByOwnerKeys: []*model.APIKey{
		{Base: model.Base{ID: 11, CreatedAt: time.Now()}, AgentName: "my-agent", OwnerUserID: 7, Status: model.APIKeyStatusActive},
	}}
	engine := newListGetTestEngine(repo, &jwt.Claims{UserID: 7})

	code, data := doListGet(t, engine, "/api/v1/agent/api-keys")
	if code != 0 {
		t.Fatalf("body code = %d, want 0 (success)", code)
	}
	if !repo.listByOwnerCalled {
		t.Fatal("ListByOwner was not called")
	}
	if repo.listByOwnerUserID != 7 {
		t.Fatalf("ListByOwner ownerUserID = %d, want 7 (current user)", repo.listByOwnerUserID)
	}
	if repo.listAllCalled {
		t.Fatal("ListAll admin fallback must not be used by a regular user")
	}
	items := decodeListData(t, data)
	if len(items) != 1 || items[0].ID != 11 {
		t.Fatalf("items = %+v, want exactly the own key id=11", items)
	}
}

func TestListAdminUsesListAllWithQueryFilter(t *testing.T) {
	repo := &spyAPIKeyRepo{listAllKeys: []*model.APIKey{
		{Base: model.Base{ID: 21, CreatedAt: time.Now()}, AgentName: "foreign-agent", OwnerUserID: 8, Status: model.APIKeyStatusActive},
	}}
	engine := newListGetTestEngine(repo, &jwt.Claims{UserID: 7, Roles: []string{"admin"}})

	code, data := doListGet(t, engine, "/api/v1/agent/api-keys?page=2&page_size=5&owner_user_id=8&status=active")
	if code != 0 {
		t.Fatalf("body code = %d, want 0 (success)", code)
	}
	if !repo.listAllCalled {
		t.Fatal("ListAll was not called for admin")
	}
	if repo.listByOwnerCalled {
		t.Fatal("ListByOwner must not be used by admin")
	}
	filter := repo.listAllFilter
	if filter.Page != 2 || filter.PageSize != 5 || filter.Status != "active" {
		t.Fatalf("filter = %+v, want page=2 page_size=5 status=active", filter)
	}
	if filter.OwnerUserID == nil || *filter.OwnerUserID != 8 {
		t.Fatalf("filter.OwnerUserID = %v, want 8", filter.OwnerUserID)
	}
	items := decodeListData(t, data)
	if len(items) != 1 || items[0].ID != 21 {
		t.Fatalf("items = %+v, want exactly the key id=21", items)
	}
}

func TestGetRegularUserCannotSeeOthersKey(t *testing.T) {
	repo := &spyAPIKeyRepo{} // owner-scoped lookup misses → (nil, nil)
	engine := newListGetTestEngine(repo, &jwt.Claims{UserID: 7})

	code, _ := doListGet(t, engine, "/api/v1/agent/api-keys/42")
	if code != http.StatusNotFound {
		t.Fatalf("body code = %d, want 404", code)
	}
	if !repo.findByIDForOwnerCalled {
		t.Fatal("FindByIDForOwner was not called")
	}
	if got := repo.findByIDForOwnerArgs; got != [2]uint64{42, 7} {
		t.Fatalf("FindByIDForOwner args = %v, want [42 7] (id, current user)", got)
	}
	if repo.findByIDCalled {
		t.Fatal("FindByID admin fallback must not be used by a regular user")
	}
}

func TestGetAdminCanSeeOthersKey(t *testing.T) {
	repo := &spyAPIKeyRepo{findByIDKey: &model.APIKey{
		Base: model.Base{ID: 42, CreatedAt: time.Now()}, AgentName: "foreign-agent", OwnerUserID: 8, Status: model.APIKeyStatusActive,
	}}
	engine := newListGetTestEngine(repo, &jwt.Claims{UserID: 7, Roles: []string{"admin"}})

	code, data := doListGet(t, engine, "/api/v1/agent/api-keys/42")
	if code != 0 {
		t.Fatalf("body code = %d, want 0 (success)", code)
	}
	if !repo.findByIDCalled {
		t.Fatal("FindByID was not called for admin")
	}
	if repo.findByIDForOwnerCalled {
		t.Fatal("owner-scoped lookup must not be used by admin")
	}
	item := decodeGetData(t, data)
	if item.ID != 42 || item.AgentName != "foreign-agent" {
		t.Fatalf("item = %+v, want id=42 agent_name=foreign-agent", item)
	}
}

func TestGetActiveButPastExpiryShowsExpired(t *testing.T) {
	past := time.Now().Add(-time.Hour)
	repo := &spyAPIKeyRepo{findByIDForOwnerKey: &model.APIKey{
		Base: model.Base{ID: 5, CreatedAt: time.Now()}, AgentName: "my-agent", OwnerUserID: 7,
		Status: model.APIKeyStatusActive, ExpiresAt: &past,
	}}
	engine := newListGetTestEngine(repo, &jwt.Claims{UserID: 7})

	code, data := doListGet(t, engine, "/api/v1/agent/api-keys/5")
	if code != 0 {
		t.Fatalf("body code = %d, want 0 (success)", code)
	}
	item := decodeGetData(t, data)
	if item.Status != string(model.APIKeyStatusExpired) {
		t.Fatalf("status = %q, want %q (active in db but past expires_at)", item.Status, model.APIKeyStatusExpired)
	}
}

func TestGetWithNonNumericIDReturns400(t *testing.T) {
	repo := &spyAPIKeyRepo{}
	engine := newListGetTestEngine(repo, &jwt.Claims{UserID: 7})

	code, _ := doListGet(t, engine, "/api/v1/agent/api-keys/abc")
	if code != http.StatusBadRequest {
		t.Fatalf("body code = %d, want 400", code)
	}
	if repo.findByIDCalled || repo.findByIDForOwnerCalled {
		t.Fatal("repository must not be called for an invalid id")
	}
}
