// Phase 3（配额 + OpenAPI 端点）的进程内集成验收（任务 P3-T10），
// 覆盖 docs/plan/001-agent-mcp-development-plan.md §3.3/§3.4 的验收标准：
//
//	配额（§3.3）：
//	① 单 key rpm=3 时连续 5 请求 → 前 3 放行、后 2 body code 429 + Retry-After 头
//	② 新分钟窗口重新计数（redis 侧新桶 → 放行恢复）
//	③ enabled=false 时 10 请求无 429（兼验证 rdb=nil 时直通不 panic）
//
//	OpenAPI（§3.4）：
//	④ 有效 agent key GET /api/v1/openapi.json → 真 HTTP 200 + Content-Type JSON
//	  + paths=35 + securityDefinitions 恰为 agentApiKey/bearerAuth 两键
//	⑤ 无效 key → body code 401
//	⑥ 回归：agent key POST /api/v1/caasm/ingest → body code 403（路由白名单）
//	⑦ 路由漂移测试存在性弱检查（internal/handler/openapi/drift_test.go）
//
// 引导方式照抄 phase1_integration_test.go（P1-T14）：sqlite in-memory +
// 镜像 users 表 struct（复用本包 phase1User）+ 真实 GormAPIKeyRepository +
// 真实中间件链，顺序对齐 router.go:59-67：CombinedAuth → Quota → AgentRouteAllowlist。
//
// 注入限制的说明（诚实记录）：
// middleware.quotaWithStore / middleware.now 均为包内私有，tests/e2e 无法直接注入
// fake quotaStore 或假时钟（quota_test.go 的注入方式仅在 middleware 包内可用），
// 且 go.mod 无 miniredis、本任务不允许改 go.mod 加依赖。因此配额链路用
// "真实 middleware.Quota + 真实 go-redis 客户端 + 进程内最小 RESP 协议替身"
// （fakeRedisServer，只实现 INCR/EXPIRE/PING/CLIENT），wire 级还原生产链路。
// 假时钟无法跨包注入的替代：fakeRedisServer 的虚拟窗口（virtualWindow）把
// 分钟窗口后缀替换为测试可控值——既免疫真实分钟边界跨过导致的 flake，
// 也能模拟"跨入新窗口后 redis 侧重新计数"。中间件侧"新分钟构造新 key"的逻辑
// 由 middleware 包内单测 TestQuotaWindowReset（包级 now 注入）覆盖，见场景②注释。
package e2e

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	goredis "github.com/redis/go-redis/v9"
	"github.com/stretchr/testify/require"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"

	"security-center-backend/internal/config"
	openapihandler "security-center-backend/internal/handler/openapi"
	"security-center-backend/internal/middleware"
	"security-center-backend/internal/model"
	"security-center-backend/internal/pkg/response"
	"security-center-backend/internal/repository"
	"security-center-backend/internal/service/jwt"
)

const (
	phase3RawKey  = "sk-agent-e2e-phase3-test-key"
	phase3OwnerID = 77
)

// ---------------------------------------------------------------------------
// 进程内最小 Redis(RESP) 替身：供真实 go-redis + 真实 middleware.Quota 使用。
// ---------------------------------------------------------------------------

// fakeRedisServer 只实现 INCR/EXPIRE/PING/CLIENT 等配额链路需要的命令。
// virtualize=true 时 INCR/EXPIRE 把 key 的真实分钟窗口后缀替换为 virtualWindow
// 对应的虚拟桶，使测试与真实分钟边界解耦（见文件头注释）。
type fakeRedisServer struct {
	addr string
	ln   net.Listener

	mu            sync.Mutex
	virtualize    bool
	virtualWindow int64
	counts        map[string]int64 // 虚拟化后的 key → 计数
	ttls          map[string]int64 // 虚拟化后的 key → EXPIRE 秒数
	wireKeys      []string         // 线上真实出现的 INCR key（含真实分钟窗口）
}

// vkeyOf 把线上 key 映射到虚拟桶 key（调用时必须已持锁）。
func (f *fakeRedisServer) vkeyOf(raw string) string {
	if !f.virtualize {
		return raw
	}
	idx := strings.LastIndex(raw, ":")
	if idx < 0 {
		return raw
	}
	return raw[:idx] + ":vw" + strconv.FormatInt(f.virtualWindow, 10)
}

func (f *fakeRedisServer) serveConn(conn net.Conn) {
	defer func() { _ = conn.Close() }()
	r := bufio.NewReader(conn)
	w := bufio.NewWriter(conn)
	for {
		args, err := readRedisCommand(r)
		if err != nil {
			return
		}
		if _, err := w.WriteString(f.execute(args)); err != nil {
			return
		}
		if err := w.Flush(); err != nil {
			return
		}
	}
}

func (f *fakeRedisServer) execute(args []string) string {
	if len(args) == 0 {
		return "-ERR empty command\r\n"
	}
	switch strings.ToUpper(args[0]) {
	case "PING":
		return "+PONG\r\n"
	case "INCR":
		f.mu.Lock()
		defer f.mu.Unlock()
		f.wireKeys = append(f.wireKeys, args[1])
		vkey := f.vkeyOf(args[1])
		f.counts[vkey]++
		return fmt.Sprintf(":%d\r\n", f.counts[vkey])
	case "EXPIRE":
		f.mu.Lock()
		defer f.mu.Unlock()
		sec, err := strconv.ParseInt(args[2], 10, 64)
		if err != nil {
			return "-ERR value is not an integer\r\n"
		}
		f.ttls[f.vkeyOf(args[1])] = sec
		return ":1\r\n"
	case "CLIENT", "SELECT", "AUTH":
		return "+OK\r\n"
	default:
		// 含 HELLO: 回 Redis 错误, go-redis 据此按"服务端无 HELLO"回退 RESP2 继续
		// (redis.go initConn: isRedisError 才放行; 回 +OK 会解析 map 失败直接断连)。
		return "-ERR unknown command\r\n"
	}
}

// readRedisCommand 解析一条 RESP 数组命令（go-redis 恒以 bulk-string 数组发命令）。
func readRedisCommand(r *bufio.Reader) ([]string, error) {
	line, err := r.ReadString('\n')
	if err != nil {
		return nil, err
	}
	line = strings.TrimRight(line, "\r\n")
	if !strings.HasPrefix(line, "*") {
		return nil, fmt.Errorf("expected RESP array, got %q", line)
	}
	n, err := strconv.Atoi(line[1:])
	if err != nil {
		return nil, err
	}
	args := make([]string, 0, n)
	for i := 0; i < n; i++ {
		hdr, err := r.ReadString('\n')
		if err != nil {
			return nil, err
		}
		hdr = strings.TrimRight(hdr, "\r\n")
		if !strings.HasPrefix(hdr, "$") {
			return nil, fmt.Errorf("expected RESP bulk string, got %q", hdr)
		}
		l, err := strconv.Atoi(hdr[1:])
		if err != nil {
			return nil, err
		}
		buf := make([]byte, l+2) // payload + CRLF
		if _, err := io.ReadFull(r, buf); err != nil {
			return nil, err
		}
		args = append(args, string(buf[:l]))
	}
	return args, nil
}

func startFakeRedis(t *testing.T) *fakeRedisServer {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)
	f := &fakeRedisServer{
		addr:   ln.Addr().String(),
		ln:     ln,
		counts: make(map[string]int64),
		ttls:   make(map[string]int64),
	}
	go func() {
		for {
			conn, err := ln.Accept()
			if err != nil {
				return // listener 关闭, 测试结束
			}
			go f.serveConn(conn)
		}
	}()
	t.Cleanup(func() { _ = ln.Close() })
	return f
}

func (f *fakeRedisServer) setVirtualize(on bool) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.virtualize = on
}

func (f *fakeRedisServer) setVirtualWindow(n int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.virtualWindow = n
}

func (f *fakeRedisServer) count(vkey string) int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.counts[vkey]
}

func (f *fakeRedisServer) ttl(vkey string) int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.ttls[vkey]
}

func (f *fakeRedisServer) incrWireKeys() []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]string(nil), f.wireKeys...)
}

// ---------------------------------------------------------------------------
// 共享引导
// ---------------------------------------------------------------------------

// newPhase3KeyRepo 建 sqlite in-memory 库 + owner 用户 + 一把 active agent key。
func newPhase3KeyRepo(t *testing.T) (repository.APIKeyRepository, uint64) {
	t.Helper()
	gin.SetMode(gin.TestMode)

	dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
	require.NoError(t, err)
	require.NoError(t, db.AutoMigrate(&phase1User{}, &model.APIKey{}))
	require.NoError(t, db.Create(&phase1User{
		Base: model.Base{ID: phase3OwnerID}, Name: "Phase3 Owner", Email: "phase3@example.com",
	}).Error)

	keyRepo := repository.NewAPIKeyRepository(db)
	key := &model.APIKey{
		KeyPrefix:       phase3RawKey[:16],
		KeyHash:         sha256Hex(phase3RawKey),
		AgentName:       "e2e-phase3-agent",
		OwnerUserID:     phase3OwnerID,
		PermissionScope: model.StringArray{"caasm:view_assets"},
		Status:          model.APIKeyStatusActive,
	}
	require.NoError(t, keyRepo.Create(context.Background(), key))
	require.NotZero(t, key.ID)
	return keyRepo, key.ID
}

func newPhase3JWTManager() *jwt.Manager {
	return jwt.NewManager(config.JWTConfig{
		AccessSecret:     "phase3-e2e-access-secret",
		RefreshSecret:    "phase3-e2e-refresh-secret",
		AccessTokenTTL:   time.Hour,
		RefreshTokenTTL:  24 * time.Hour,
		SigningAlgorithm: "HS256",
		Issuer:           "phase3-e2e",
	})
}

// buildPhase3Engine 按 router.go:59-67 的生产顺序装配
// CombinedAuth(AgentAuth, Auth) → quota → AgentRouteAllowlist，
// 挂白名单内的 GET /api/v1/caasm/assets，返回 engine 与下游 handler 命中计数。
func buildPhase3Engine(t *testing.T, keyRepo repository.APIKeyRepository, quota gin.HandlerFunc) (*gin.Engine, *int) {
	t.Helper()
	engine := gin.New()
	api := engine.Group("/api/v1")
	api.Use(middleware.CombinedAuth(
		middleware.AgentAuth(keyRepo, config.AgentConfig{Enabled: true, KeyPrefix: "sk-agent-"}),
		middleware.Auth(newPhase3JWTManager()),
	))
	api.Use(quota) // 生产顺序：Quota 在 CombinedAuth 之后, 读其注入的 api_key_id
	api.Use(middleware.AgentRouteAllowlist())

	hits := new(int)
	api.GET("/caasm/assets", middleware.Permission("caasm:view_assets"), func(c *gin.Context) {
		*hits++
		response.Success(c, gin.H{"handler": "caasm_assets"})
	})
	return engine, hits
}

func phase3AgentHeaders() map[string]string {
	return map[string]string{"X-API-Key": phase3RawKey}
}

// doPhase3Request 发进程内请求并解析统一信封，同时返回响应头（Retry-After 断言用）。
// 响应约定同 Phase 1/2：response.Error 恒 HTTP 200 + body code，断言按 body code。
func doPhase3Request(t *testing.T, engine *gin.Engine, method, path string, headers map[string]string) (phase1Result, http.Header) {
	t.Helper()
	w := httptest.NewRecorder()
	req := httptest.NewRequest(method, path, nil)
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	engine.ServeHTTP(w, req)
	var body response.APIResponse
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "响应非统一信封 JSON, raw=%q", w.Body.String())
	return phase1Result{httpStatus: w.Code, body: body}, w.Header()
}

// ---------------------------------------------------------------------------
// 配额验收（§3.3）
// ---------------------------------------------------------------------------

func TestPhase3Quota(t *testing.T) {
	store := startFakeRedis(t)
	// DisableIndentity(go-redis v9.5.3 上游拼写如此): 默认建连后发 CLIENT SETINFO
	// (fire-and-forget 不读回包), 替身若回 +OK 会造成回包错位; 关掉后握手面最小。
	rdb := goredis.NewClient(&goredis.Options{Addr: store.addr, DisableIndentity: true})
	t.Cleanup(func() { _ = rdb.Close() })

	keyRepo, keyID := newPhase3KeyRepo(t)
	engine, hits := buildPhase3Engine(t, keyRepo, middleware.Quota(rdb, true, 3))
	vkeyPrefix := fmt.Sprintf("quota:agent:%d:", keyID)

	t.Run("1_burst_first3_pass_then_429_with_retry_after", func(t *testing.T) {
		// 虚拟窗口与真实分钟边界解耦：wire key 仍由中间件按真实 now() 构造，
		// fake redis 只把计数归到虚拟桶，跨分钟跑本测试不会 flake。
		store.setVirtualize(true)
		store.setVirtualWindow(1)

		for i := 1; i <= 3; i++ {
			res, _ := doPhase3Request(t, engine, http.MethodGet, "/api/v1/caasm/assets", phase3AgentHeaders())
			require.Equal(t, http.StatusOK, res.httpStatus, "信封约定: 成功分支 HTTP 200")
			require.Equal(t, 0, res.body.Code, "第 %d 个请求应在限额内放行, body=%+v", i, res.body)
		}
		for i := 4; i <= 5; i++ {
			res, hdr := doPhase3Request(t, engine, http.MethodGet, "/api/v1/caasm/assets", phase3AgentHeaders())
			require.Equal(t, http.StatusOK, res.httpStatus, "信封约定: 错误也返回 HTTP 200")
			require.Equal(t, http.StatusTooManyRequests, res.body.Code, "第 %d 个请求应 429, body=%+v", i, res.body)
			require.Equal(t, "rate limit exceeded", res.body.Message)
			require.NotEmpty(t, hdr.Get("Retry-After"), "429 响应必须携带 Retry-After 头")
			require.Equal(t, "60", hdr.Get("Retry-After"))
			require.Nil(t, res.body.Data, "被限流时下游 handler 不应执行")
		}
		require.Equal(t, 3, *hits, "超限请求应 Abort, 下游 handler 只执行 3 次")

		// wire 级核验：中间件构造的真实 key 形态 quota:agent:<keyID>:<分钟窗口>，
		// 窗口为分钟对齐 unix 秒；首个请求对该窗口 key EXPIRE 65s（quotaKeyTTL）。
		wire := store.incrWireKeys()
		require.Len(t, wire, 5, "5 个请求都应触发一次 INCR")
		for _, k := range wire {
			require.True(t, strings.HasPrefix(k, vkeyPrefix), "wire key 形态异常: %q", k)
			window, err := strconv.ParseInt(strings.TrimPrefix(k, vkeyPrefix), 10, 64)
			require.NoError(t, err, "窗口后缀应为整数: %q", k)
			require.Zero(t, window%60, "窗口应为分钟对齐的 unix 秒: %q", k)
		}
		require.Equal(t, int64(5), store.count(vkeyPrefix+"vw1"))
		require.Equal(t, int64(65), store.ttl(vkeyPrefix+"vw1"), "首个请求应对窗口 key 设置 65s TTL")
	})

	t.Run("2_new_window_recounts", func(t *testing.T) {
		// 中间件包内私有 now 无法跨包注入假时钟，等 65s 又不可行；
		// 这里把 fake redis 切到下一个虚拟窗口，模拟"redis 侧出现新窗口桶、计数清零"，
		// 验证端到端可观察行为：窗口轮转后限流解除、重新计数。
		// 中间件侧"新分钟构造新 key"的单元逻辑由 middleware 包内
		// TestQuotaWindowReset（包级 now 注入）覆盖。
		before := *hits
		store.setVirtualWindow(2)

		res, _ := doPhase3Request(t, engine, http.MethodGet, "/api/v1/caasm/assets", phase3AgentHeaders())
		require.Equal(t, 0, res.body.Code, "新窗口应重新计数并放行, body=%+v", res.body)
		require.Equal(t, before+1, *hits, "新窗口请求应到达下游 handler")
		require.Equal(t, int64(1), store.count(vkeyPrefix+"vw2"), "新窗口桶应从 1 重新计数")
		require.Equal(t, int64(5), store.count(vkeyPrefix+"vw1"), "旧窗口桶计数保持不变")
		require.Equal(t, int64(65), store.ttl(vkeyPrefix+"vw2"), "新窗口首请求同样应设置 TTL")
	})

	t.Run("3_disabled_no_429_and_nil_redis_safe", func(t *testing.T) {
		// enabled=false → 中间件直通；rdb=nil 时不会触碰 store, 不 panic
		// （对应 application.go:452-456 redis 未配置时不装配的 nil 安全语义）。
		disabledEngine, disabledHits := buildPhase3Engine(t, keyRepo, middleware.Quota(nil, false, 3))
		for i := 0; i < 10; i++ {
			res, hdr := doPhase3Request(t, disabledEngine, http.MethodGet, "/api/v1/caasm/assets", phase3AgentHeaders())
			require.Equal(t, 0, res.body.Code, "配额关闭时第 %d 个请求不应 429, body=%+v", i+1, res.body)
			require.Empty(t, hdr.Get("Retry-After"))
		}
		require.Equal(t, 10, *disabledHits)
	})
}

// ---------------------------------------------------------------------------
// OpenAPI 验收（§3.4）
// ---------------------------------------------------------------------------

func TestPhase3OpenAPI(t *testing.T) {
	keyRepo, _ := newPhase3KeyRepo(t)

	// 全链：CombinedAuth + AgentRouteAllowlist + openapi handler（P3-T5 上线形态,
	// router.go:77 protected.GET("/openapi.json", ...) 无路由级 Permission）。
	engine := gin.New()
	api := engine.Group("/api/v1")
	api.Use(middleware.CombinedAuth(
		middleware.AgentAuth(keyRepo, config.AgentConfig{Enabled: true, KeyPrefix: "sk-agent-"}),
		middleware.Auth(newPhase3JWTManager()),
	))
	api.Use(middleware.AgentRouteAllowlist())
	api.GET("/openapi.json", openapihandler.NewHandler().GetSpec)
	// 无权限码路由：复刻 router.go 中 caasmIngestGroup 的形态（同 phase1）。
	api.POST("/caasm/ingest", func(c *gin.Context) {
		response.Success(c, gin.H{"handler": "caasm_ingest"})
	})

	t.Run("1_valid_agent_key_gets_full_spec", func(t *testing.T) {
		// GetSpec 输出裸 JSON（非统一信封）, 这里断言真实 HTTP 状态码与 Content-Type。
		w := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
		req.Header.Set("X-API-Key", phase3RawKey)
		engine.ServeHTTP(w, req)

		require.Equal(t, http.StatusOK, w.Code, "body 头 200 字节: %q", w.Body.String()[:min(200, w.Body.Len())])
		require.Contains(t, w.Header().Get("Content-Type"), "application/json")

		var doc struct {
			Swagger             string                     `json:"swagger"`
			Paths               map[string]json.RawMessage `json:"paths"`
			SecurityDefinitions map[string]json.RawMessage `json:"securityDefinitions"`
		}
		require.NoError(t, json.Unmarshal(w.Body.Bytes(), &doc), "openapi.json 应为合法 JSON")
		require.Equal(t, "2.0", doc.Swagger)
		require.Len(t, doc.Paths, 38, "spec 应含全部 35 个只读端点 + 6 条写端点（38 paths / 41 operations，口径同 drift_test）")
		require.Len(t, doc.SecurityDefinitions, 2, "securityDefinitions 应恰为两个 scheme")
		require.Contains(t, doc.SecurityDefinitions, "agentApiKey")
		require.Contains(t, doc.SecurityDefinitions, "bearerAuth")
	})

	t.Run("2_invalid_key_401", func(t *testing.T) {
		res, _ := doPhase3Request(t, engine, http.MethodGet, "/api/v1/openapi.json",
			map[string]string{"X-API-Key": "sk-agent-e2e-phase3-wrong-key"})
		require.Equal(t, http.StatusOK, res.httpStatus, "信封约定: 错误也返回 HTTP 200")
		require.Equal(t, http.StatusUnauthorized, res.body.Code, "body=%+v", res.body)
		require.Equal(t, "invalid api key", res.body.Message)
		require.Nil(t, res.body.Data, "认证失败不应泄漏 spec")
	})

	t.Run("3_agent_key_post_ingest_403_allowlist_regression", func(t *testing.T) {
		res, _ := doPhase3Request(t, engine, http.MethodPost, "/api/v1/caasm/ingest", phase3AgentHeaders())
		require.Equal(t, http.StatusOK, res.httpStatus)
		require.Equal(t, http.StatusForbidden, res.body.Code, "body=%+v", res.body)
		require.Equal(t, "agent not allowed on this route", res.body.Message)
		require.Nil(t, res.body.Data, "ingest handler 不应执行")
	})

	t.Run("4_drift_guard_test_exists", func(t *testing.T) {
		// 漂移测试本体在 openapi_test 外部包（import internal/router 防循环依赖），
		// tests/e2e 无法直接引用其断言函数；弱检查文件存在。
		// 其 PASS 结果由 `go test ./internal/handler/openapi/ -run TestOpenAPISpecRoutesAreRegistered`
		// 保障（本任务验收时已一并跑过, 见任务报告）。
		_, err := os.Stat("../../internal/handler/openapi/drift_test.go")
		require.NoError(t, err, "路由漂移测试 internal/handler/openapi/drift_test.go 缺失")
	})
}
