//go:build integration

// Package integration contains Sprint 11 E2E tests (E11-01b).
// Batch 2 covers: admin policies CRUD, feature flags admin API, audit log
// query, cost aggregation, compliance entry CRUD, and GDPR erasure.
//
// Run with: go test -tags integration -v ./test/integration/ -run TestE2E_Admin
// All tests use httptest.Server + in-memory stubs — no external services needed.
package integration

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

	"github.com/go-chi/chi/v5"
	chimiddleware "github.com/go-chi/chi/v5/middleware"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap"

	"github.com/veylant/ia-gateway/internal/admin"
	"github.com/veylant/ia-gateway/internal/auditlog"
	"github.com/veylant/ia-gateway/internal/compliance"
	"github.com/veylant/ia-gateway/internal/flags"
	"github.com/veylant/ia-gateway/internal/middleware"
	"github.com/veylant/ia-gateway/internal/routing"
)

// ─── Admin server builder ─────────────────────────────────────────────────────

type adminServerOptions struct {
	store     routing.RuleStore
	auditLog  auditlog.Logger
	flagStore flags.FlagStore
	verifier  middleware.TokenVerifier
	// compStore: if nil, a memComplianceStore is created automatically
	compStore compliance.ComplianceStore
}

// buildAdminServer creates a fully wired httptest.Server exposing /v1/admin/*
// and /v1/admin/compliance/* with in-memory stores. No external services needed.
func buildAdminServer(t *testing.T, opts adminServerOptions) *httptest.Server {
	t.Helper()

	if opts.store == nil {
		opts.store = routing.NewMemStore()
	}
	if opts.auditLog == nil {
		opts.auditLog = auditlog.NewMemLogger()
	}
	if opts.flagStore == nil {
		opts.flagStore = flags.NewMemFlagStore()
	}
	if opts.verifier == nil {
		opts.verifier = &middleware.MockVerifier{Claims: e2eAdminClaims()}
	}
	if opts.compStore == nil {
		opts.compStore = newMemComplianceStore()
	}

	cache := routing.NewRuleCache(opts.store, 5*time.Minute, zap.NewNop())
	adminHandler := admin.NewWithAudit(opts.store, cache, opts.auditLog, zap.NewNop()).
		WithFlagStore(opts.flagStore)

	compHandler := compliance.New(opts.compStore, zap.NewNop()).
		WithAudit(opts.auditLog)

	r := chi.NewRouter()
	r.Use(chimiddleware.Recoverer)

	r.Route("/v1", func(r chi.Router) {
		r.Use(middleware.Auth(opts.verifier))
		r.Route("/admin", adminHandler.Routes)
		r.Route("/admin/compliance", compHandler.Routes)
	})

	srv := httptest.NewServer(r)
	t.Cleanup(srv.Close)
	return srv
}

// ─── In-memory ComplianceStore for tests ─────────────────────────────────────

type memComplianceStore struct {
	mu      sync.Mutex
	entries map[string]compliance.ProcessingEntry
	seq     int
}

func newMemComplianceStore() *memComplianceStore {
	return &memComplianceStore{entries: make(map[string]compliance.ProcessingEntry)}
}

func (m *memComplianceStore) List(_ context.Context, tenantID string) ([]compliance.ProcessingEntry, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	var out []compliance.ProcessingEntry
	for _, e := range m.entries {
		if e.TenantID == tenantID {
			out = append(out, e)
		}
	}
	return out, nil
}

func (m *memComplianceStore) Get(_ context.Context, id, tenantID string) (compliance.ProcessingEntry, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	e, ok := m.entries[id]
	if !ok || e.TenantID != tenantID {
		return compliance.ProcessingEntry{}, compliance.ErrNotFound
	}
	return e, nil
}

func (m *memComplianceStore) Create(_ context.Context, entry compliance.ProcessingEntry) (compliance.ProcessingEntry, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.seq++
	entry.ID = fmt.Sprintf("comp-%d", m.seq)
	entry.CreatedAt = time.Now()
	entry.UpdatedAt = time.Now()
	m.entries[entry.ID] = entry
	return entry, nil
}

func (m *memComplianceStore) Update(_ context.Context, entry compliance.ProcessingEntry) (compliance.ProcessingEntry, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	existing, ok := m.entries[entry.ID]
	if !ok || existing.TenantID != entry.TenantID {
		return compliance.ProcessingEntry{}, compliance.ErrNotFound
	}
	entry.CreatedAt = existing.CreatedAt
	entry.UpdatedAt = time.Now()
	m.entries[entry.ID] = entry
	return entry, nil
}

func (m *memComplianceStore) Delete(_ context.Context, id, tenantID string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	e, ok := m.entries[id]
	if !ok || e.TenantID != tenantID {
		return compliance.ErrNotFound
	}
	delete(m.entries, id)
	return nil
}

// ─── Helper: doAdminJSON ──────────────────────────────────────────────────────

// doAdminJSON sends an authenticated JSON request to the test server.
func doAdminJSON(t *testing.T, method, url string, body interface{}) *http.Response {
	t.Helper()
	return doJSON(t, http.DefaultClient, method, url, "test-token", body)
}

// decodeBody decodes JSON body into dst, draining the response body.
func decodeBody(t *testing.T, resp *http.Response, dst interface{}) {
	t.Helper()
	defer resp.Body.Close()
	require.NoError(t, json.NewDecoder(resp.Body).Decode(dst))
}

// ─── E2E tests: Admin Policies ────────────────────────────────────────────────

// TestE2E_Admin_Policies_List_Empty verifies GET /v1/admin/policies returns
// 200 with an empty data array when no policies have been created.
func TestE2E_Admin_Policies_List_Empty(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})

	resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/policies", nil)
	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var body struct {
		Data []interface{} `json:"data"`
	}
	decodeBody(t, resp, &body)
	assert.Empty(t, body.Data)
}

// TestE2E_Admin_Policies_Create_Returns201 verifies POST /v1/admin/policies
// creates a policy and returns 201 with a non-empty ID.
func TestE2E_Admin_Policies_Create_Returns201(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})

	payload := map[string]interface{}{
		"name":       "HR policy",
		"priority":   10,
		"is_enabled": true,
		"conditions": []map[string]interface{}{},
		"action":     map[string]string{"provider": "openai", "model": "gpt-4o"},
	}

	resp := doAdminJSON(t, http.MethodPost, srv.URL+"/v1/admin/policies", payload)
	assert.Equal(t, http.StatusCreated, resp.StatusCode)

	var created routing.RoutingRule
	decodeBody(t, resp, &created)
	assert.NotEmpty(t, created.ID)
	assert.Equal(t, "HR policy", created.Name)
	assert.Equal(t, e2eTenantID, created.TenantID)
}

// TestE2E_Admin_Policies_Update_Returns200 creates a policy then updates it
// and verifies the returned rule contains the new name.
func TestE2E_Admin_Policies_Update_Returns200(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})
	url := srv.URL + "/v1/admin/policies"

	// Create
	payload := map[string]interface{}{
		"name":       "initial",
		"is_enabled": true,
		"conditions": []map[string]interface{}{},
		"action":     map[string]string{"provider": "openai", "model": "gpt-4o"},
	}
	createResp := doAdminJSON(t, http.MethodPost, url, payload)
	require.Equal(t, http.StatusCreated, createResp.StatusCode)
	var created routing.RoutingRule
	decodeBody(t, createResp, &created)

	// Update
	payload["name"] = "updated"
	updateResp := doAdminJSON(t, http.MethodPut, url+"/"+created.ID, payload)
	assert.Equal(t, http.StatusOK, updateResp.StatusCode)

	var updated routing.RoutingRule
	decodeBody(t, updateResp, &updated)
	assert.Equal(t, "updated", updated.Name)
	assert.Equal(t, created.ID, updated.ID)
}

// TestE2E_Admin_Policies_Delete_Returns204 creates a policy then deletes it,
// verifying 204 No Content on delete and 404 on subsequent GET.
func TestE2E_Admin_Policies_Delete_Returns204(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})
	url := srv.URL + "/v1/admin/policies"

	// Create
	payload := map[string]interface{}{
		"name":       "to-delete",
		"is_enabled": true,
		"conditions": []map[string]interface{}{},
		"action":     map[string]string{"provider": "openai", "model": "gpt-4o"},
	}
	createResp := doAdminJSON(t, http.MethodPost, url, payload)
	require.Equal(t, http.StatusCreated, createResp.StatusCode)
	var created routing.RoutingRule
	decodeBody(t, createResp, &created)

	// Delete
	delResp := doAdminJSON(t, http.MethodDelete, url+"/"+created.ID, nil)
	assert.Equal(t, http.StatusNoContent, delResp.StatusCode)
	delResp.Body.Close()

	// GET should be 404
	getResp := doAdminJSON(t, http.MethodGet, url+"/"+created.ID, nil)
	assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
	getResp.Body.Close()
}

// TestE2E_Admin_Policies_SeedTemplate_HR verifies that POST
// /v1/admin/policies/seed/hr returns 201 and creates a rule in the store.
func TestE2E_Admin_Policies_SeedTemplate_HR(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})

	resp := doAdminJSON(t, http.MethodPost, srv.URL+"/v1/admin/policies/seed/hr", nil)
	assert.Equal(t, http.StatusCreated, resp.StatusCode)

	var created routing.RoutingRule
	decodeBody(t, resp, &created)
	assert.NotEmpty(t, created.ID)
	assert.NotEmpty(t, created.Name)
	assert.Equal(t, e2eTenantID, created.TenantID)
}

// ─── E2E tests: Feature Flags Admin API ──────────────────────────────────────

// TestE2E_Admin_Flags_List_Set_Delete exercises the full flag lifecycle:
// GET (empty), PUT to set, GET (present), DELETE, GET (gone).
func TestE2E_Admin_Flags_List_Set_Delete(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})
	flagsURL := srv.URL + "/v1/admin/flags"

	// GET initial state — may contain global defaults; check 200 at minimum.
	listResp := doAdminJSON(t, http.MethodGet, flagsURL, nil)
	assert.Equal(t, http.StatusOK, listResp.StatusCode)
	listResp.Body.Close()

	// PUT — disable pii_enabled for this tenant
	putResp := doAdminJSON(t, http.MethodPut, flagsURL+"/pii_enabled",
		map[string]bool{"enabled": false})
	assert.Equal(t, http.StatusOK, putResp.StatusCode)

	var flag flags.FeatureFlag
	decodeBody(t, putResp, &flag)
	assert.Equal(t, "pii_enabled", flag.Name)
	assert.False(t, flag.IsEnabled)

	// GET list — should now include pii_enabled for this tenant
	listResp2 := doAdminJSON(t, http.MethodGet, flagsURL, nil)
	assert.Equal(t, http.StatusOK, listResp2.StatusCode)
	var listBody struct {
		Data []flags.FeatureFlag `json:"data"`
	}
	decodeBody(t, listResp2, &listBody)
	found := false
	for _, f := range listBody.Data {
		if f.Name == "pii_enabled" && f.TenantID == e2eTenantID {
			found = true
			assert.False(t, f.IsEnabled)
		}
	}
	assert.True(t, found, "pii_enabled should be in the flag list after PUT")

	// DELETE
	delResp := doAdminJSON(t, http.MethodDelete, flagsURL+"/pii_enabled", nil)
	assert.Equal(t, http.StatusNoContent, delResp.StatusCode)
	delResp.Body.Close()

	// After DELETE, the tenant-specific flag should be gone.
	// A subsequent GET /flags should not include the tenant-specific pii_enabled.
	listResp3 := doAdminJSON(t, http.MethodGet, flagsURL, nil)
	assert.Equal(t, http.StatusOK, listResp3.StatusCode)
	var listBody3 struct {
		Data []flags.FeatureFlag `json:"data"`
	}
	decodeBody(t, listResp3, &listBody3)
	for _, f := range listBody3.Data {
		if f.Name == "pii_enabled" {
			assert.NotEqual(t, e2eTenantID, f.TenantID,
				"tenant-specific pii_enabled should be deleted")
		}
	}
}

// ─── E2E tests: Audit Logs ────────────────────────────────────────────────────

// TestE2E_Admin_Logs_Query_ReturnsEntries pre-seeds a MemLogger with one
// entry and verifies GET /v1/admin/logs returns it.
func TestE2E_Admin_Logs_Query_ReturnsEntries(t *testing.T) {
	memLog := auditlog.NewMemLogger()
	memLog.Log(auditlog.AuditEntry{
		RequestID:      "req-e2e-01",
		TenantID:       e2eTenantID,
		UserID:         e2eUserID,
		Timestamp:      time.Now(),
		ModelRequested: "gpt-4o",
		Provider:       "openai",
		TokenTotal:     42,
		Status:         "ok",
	})

	srv := buildAdminServer(t, adminServerOptions{auditLog: memLog})

	resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/logs", nil)
	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var result auditlog.AuditResult
	decodeBody(t, resp, &result)
	require.Equal(t, 1, result.Total, "should have exactly one log entry")
	assert.Equal(t, "req-e2e-01", result.Data[0].RequestID)
}

// ─── E2E tests: Costs ─────────────────────────────────────────────────────────

// TestE2E_Admin_Costs_GroupBy_Provider verifies GET /v1/admin/costs?group_by=provider
// returns a data array that includes an openai cost summary.
func TestE2E_Admin_Costs_GroupBy_Provider(t *testing.T) {
	memLog := auditlog.NewMemLogger()
	// Seed two entries with different providers
	for i := 0; i < 3; i++ {
		memLog.Log(auditlog.AuditEntry{
			RequestID:  fmt.Sprintf("req-%d", i),
			TenantID:   e2eTenantID,
			Provider:   "openai",
			TokenTotal: 100,
			CostUSD:    0.002,
			Status:     "ok",
		})
	}
	memLog.Log(auditlog.AuditEntry{
		RequestID:  "req-mistral",
		TenantID:   e2eTenantID,
		Provider:   "mistral",
		TokenTotal: 50,
		CostUSD:    0.001,
		Status:     "ok",
	})

	srv := buildAdminServer(t, adminServerOptions{auditLog: memLog})

	resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/costs?group_by=provider", nil)
	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var result auditlog.CostResult
	decodeBody(t, resp, &result)
	require.NotEmpty(t, result.Data)

	keySet := make(map[string]bool)
	for _, s := range result.Data {
		keySet[s.Key] = true
	}
	assert.True(t, keySet["openai"], "openai should appear in cost breakdown")
	assert.True(t, keySet["mistral"], "mistral should appear in cost breakdown")
}

// ─── E2E tests: Compliance Entry CRUD ────────────────────────────────────────

// TestE2E_Compliance_Entry_CRUD exercises the full processing entry lifecycle:
// POST → GET → PUT → DELETE.
func TestE2E_Compliance_Entry_CRUD(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})
	baseURL := srv.URL + "/v1/admin/compliance/entries"

	// POST — create entry
	createPayload := map[string]interface{}{
		"use_case_name":     "Chatbot RH",
		"legal_basis":       "legitimate_interest",
		"purpose":           "Automatisation des réponses RH internes",
		"data_categories":   []string{"identifiers", "professional"},
		"recipients":        []string{"HR team"},
		"processors":        []string{"OpenAI Inc."},
		"retention_period":  "12 months",
		"security_measures": "AES-256 encryption, access control",
		"controller_name":   "Veylant E2E Corp",
	}
	createResp := doAdminJSON(t, http.MethodPost, baseURL, createPayload)
	require.Equal(t, http.StatusCreated, createResp.StatusCode)

	var created compliance.ProcessingEntry
	decodeBody(t, createResp, &created)
	require.NotEmpty(t, created.ID)
	assert.Equal(t, "Chatbot RH", created.UseCaseName)
	assert.Equal(t, e2eTenantID, created.TenantID)

	// GET — retrieve entry
	getResp := doAdminJSON(t, http.MethodGet, baseURL+"/"+created.ID, nil)
	assert.Equal(t, http.StatusOK, getResp.StatusCode)
	var fetched compliance.ProcessingEntry
	decodeBody(t, getResp, &fetched)
	assert.Equal(t, created.ID, fetched.ID)

	// PUT — update entry
	createPayload["use_case_name"] = "Chatbot RH v2"
	updateResp := doAdminJSON(t, http.MethodPut, baseURL+"/"+created.ID, createPayload)
	assert.Equal(t, http.StatusOK, updateResp.StatusCode)
	var updated compliance.ProcessingEntry
	decodeBody(t, updateResp, &updated)
	assert.Equal(t, "Chatbot RH v2", updated.UseCaseName)

	// DELETE — remove entry
	delResp := doAdminJSON(t, http.MethodDelete, baseURL+"/"+created.ID, nil)
	assert.Equal(t, http.StatusNoContent, delResp.StatusCode)
	delResp.Body.Close()

	// GET after delete — should be 404
	get2Resp := doAdminJSON(t, http.MethodGet, baseURL+"/"+created.ID, nil)
	assert.Equal(t, http.StatusNotFound, get2Resp.StatusCode)
	get2Resp.Body.Close()
}

// ─── E2E tests: GDPR Erasure ─────────────────────────────────────────────────

// TestE2E_Compliance_GDPR_Erase_Returns200 verifies DELETE
// /v1/admin/compliance/gdpr/erase/{user_id} returns 200 with status "completed".
// The handler works without a DB connection (db=nil path is exercised).
func TestE2E_Compliance_GDPR_Erase_Returns200(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})
	targetUser := "user-to-erase@corp.example"

	resp, err := makeRequest(t,
		http.MethodDelete,
		srv.URL+"/v1/admin/compliance/gdpr/erase/"+targetUser+"?reason=test-erase",
		"test-token",
		nil,
	)
	require.NoError(t, err)
	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var erasure compliance.ErasureRecord
	decodeBody(t, resp, &erasure)
	assert.Equal(t, "completed", erasure.Status)
	assert.Equal(t, targetUser, erasure.TargetUser)
	assert.Equal(t, e2eTenantID, erasure.TenantID)
}

// makeRequest is a low-level helper for requests that don't have a JSON body.
func makeRequest(t *testing.T, method, url, token string, body []byte) (*http.Response, error) {
	t.Helper()
	var bodyReader *bytes.Reader
	if body != nil {
		bodyReader = bytes.NewReader(body)
	} else {
		bodyReader = bytes.NewReader(nil)
	}
	req, err := http.NewRequestWithContext(context.Background(), method, url, bodyReader)
	require.NoError(t, err)
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	return http.DefaultClient.Do(req)
}

// ─── E2E tests: Admin server — no-auth guard ──────────────────────────────────

// TestE2E_Admin_NoToken_Returns401 verifies that all admin routes are
// protected: a request without a token receives 401.
func TestE2E_Admin_NoToken_Returns401(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})

	req, err := http.NewRequestWithContext(context.Background(),
		http.MethodGet, srv.URL+"/v1/admin/policies", nil)
	require.NoError(t, err)
	// No Authorization header

	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}

// ─── E2E tests: Routing flag via proxy ────────────────────────────────────────

// TestE2E_Admin_Flags_Routing_Disabled_StillRoutes verifies that when
// routing_enabled is set to false for a tenant, the proxy falls back to
// static prefix rules and still returns 200 (not an error).
func TestE2E_Admin_Flags_Routing_Disabled_StillRoutes(t *testing.T) {
	fs := flags.NewMemFlagStore()
	// Explicitly disable routing for the E2E tenant
	_, err := fs.Set(context.Background(), e2eTenantID, "routing_enabled", false)
	require.NoError(t, err)

	// Build proxy server using the flag store with routing disabled
	srv := buildProxyServer(t, proxyServerOptions{flagStore: fs})

	resp := doJSON(t, http.DefaultClient, http.MethodPost,
		srv.URL+"/v1/chat/completions",
		"test-token",
		chatBody("gpt-4o", false),
	)
	defer resp.Body.Close()

	// Should still succeed — static prefix fallback routes gpt-4o → openai stub
	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var body map[string]interface{}
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
	choices, _ := body["choices"].([]interface{})
	assert.NotEmpty(t, choices)
}

// ─── E2E tests: Compliance list empty ────────────────────────────────────────

// TestE2E_Compliance_Entries_List_Empty verifies that GET /v1/admin/compliance/entries
// returns 200 with an empty data array on a fresh store.
func TestE2E_Compliance_Entries_List_Empty(t *testing.T) {
	srv := buildAdminServer(t, adminServerOptions{})

	resp := doAdminJSON(t, http.MethodGet, srv.URL+"/v1/admin/compliance/entries", nil)
	assert.Equal(t, http.StatusOK, resp.StatusCode)

	var body struct {
		Data []interface{} `json:"data"`
	}
	decodeBody(t, resp, &body)
	assert.Empty(t, body.Data)
}

