package openapi_test

// Drift guard between the embedded Swagger 2.0 document and the routes that
// router.RegisterRoutes actually registers: every operation the spec
// advertises must exist on a live engine, and the spec surface itself must
// match the exact endpoint sets pinned below — 35 GET paths (25 caasm +
// 10 vuln) plus the 6 vulnerability write operations. Adding, renaming or
// removing an endpoint without updating this test in the same change fails
// the gate (plan §5).
//
// The test lives in the external openapi_test package because it imports
// internal/router, which itself imports this package.

import (
	"encoding/json"
	"net/http"
	"os"
	"slices"
	"sort"
	"strings"
	"testing"

	"github.com/gin-gonic/gin"

	authhandler "security-center-backend/internal/handler/auth"
	healthhandler "security-center-backend/internal/handler/health"
	openapistatic "security-center-backend/internal/handler/openapi"
	caasmhandler "security-center-backend/internal/modules/caasm/handler"
	caasmservice "security-center-backend/internal/modules/caasm/service"
	vulnhandler "security-center-backend/internal/modules/vuln/handler"
	"security-center-backend/internal/router"
)

// wantSpecGETPaths is the exact GET surface the spec must advertise: 25 caasm
// + 10 vuln paths, sorted. A renamed, removed or added GET endpoint shows up
// here as a missing/extra pair.
var wantSpecGETPaths = []string{
	"/api/v1/caasm/api-assets",
	"/api/v1/caasm/api-assets/config/active",
	"/api/v1/caasm/assets",
	"/api/v1/caasm/assets/environments",
	"/api/v1/caasm/assets/ports/search",
	"/api/v1/caasm/assets/services",
	"/api/v1/caasm/assets/{id}",
	"/api/v1/caasm/assets/{id}/tags",
	"/api/v1/caasm/cloud-accounts",
	"/api/v1/caasm/cloud-accounts/{id}",
	"/api/v1/caasm/cloud-accounts/{id}/jobs",
	"/api/v1/caasm/dns-records",
	"/api/v1/caasm/dns-records/{id}/comments",
	"/api/v1/caasm/domains",
	"/api/v1/caasm/domains/registrars",
	"/api/v1/caasm/domains/stats",
	"/api/v1/caasm/ip-assets",
	"/api/v1/caasm/ports",
	"/api/v1/caasm/ports/high-risk",
	"/api/v1/caasm/ports/stats",
	"/api/v1/caasm/ports/{id}",
	"/api/v1/caasm/relationships/graph",
	"/api/v1/caasm/sync-jobs",
	"/api/v1/caasm/sync-jobs/{id}",
	"/api/v1/caasm/sync-jobs/{id}/events",
	"/api/v1/vulnerabilities",
	"/api/v1/vulnerabilities/{public_id}",
	"/api/v1/vulnerabilities/{public_id}/comments",
	"/api/v1/vulnerabilities/{public_id}/events",
	"/api/v1/vulnerabilities/{public_id}/viewers/candidates",
	"/api/v1/vulnerability-discovery-channels",
	"/api/v1/vulnerability-sources",
	"/api/v1/vulnerability-tags",
	"/api/v1/vulnerability-tags/stats",
	"/api/v1/vulnerability-types",
}

// specOperation identifies one method+path pair advertised by the spec.
type specOperation struct {
	method string
	path   string
}

// String renders the operation the way failure messages quote it.
func (op specOperation) String() string {
	return op.method + " " + op.path
}

// wantSpecWriteOperations is the exact write surface the spec must advertise:
// the six vulnerability write endpoints (plan §3.3).
var wantSpecWriteOperations = []specOperation{
	{http.MethodPost, "/api/v1/vulnerabilities"},
	{http.MethodPut, "/api/v1/vulnerabilities/{public_id}"},
	{http.MethodPost, "/api/v1/vulnerabilities/{public_id}/transition"},
	{http.MethodPost, "/api/v1/vulnerabilities/{public_id}/comments"},
	{http.MethodPost, "/api/v1/vulnerabilities/uploads"},
	{http.MethodPost, "/api/v1/vulnerabilities/uploads/confirm"},
}

// buildDriftTestEngine registers the real routes on a test engine. Handlers
// that stay nil in Dependencies (Users, Roles, Scan, ...) skip their route
// groups; none of them serve the endpoints the spec advertises (35 GET:
// caasm 25 + vuln 10; 6 vuln writes). The CAASM handler gets non-nil
// accounts/jobs/apiRoutes services — built on nil repositories, safe here
// because no request is ever served — since RegisterCloudAccountRoutes,
// RegisterSyncJobRoutes and RegisterAPIRouteAssetRoutes skip registration
// when those services are nil. The webhook handler stays nil; its routes are
// POST-only and outside the spec surface.
func buildDriftTestEngine(t *testing.T) *gin.Engine {
	t.Helper()
	gin.SetMode(gin.TestMode)
	engine := gin.New()
	router.RegisterRoutes(engine, router.Dependencies{
		Auth:   authhandler.NewHandler(nil, nil, nil, nil),
		Health: healthhandler.NewHandler(),
		CAASM: caasmhandler.New(
			nil, nil, nil, nil,
			caasmservice.NewCloudAccountService(nil, nil, nil),
			caasmservice.NewSyncJobService(nil, nil),
			nil, nil,
			caasmservice.NewAPIRouteAssetService(nil),
			nil, nil, nil,
		),
		VulnModule: vulnhandler.NewModule(
			vulnhandler.New(nil, nil, nil),
			vulnhandler.NewTagHandler(nil),
			vulnhandler.NewVulnerabilityTypeHandler(nil),
			vulnhandler.NewSourceHandler(nil),
			vulnhandler.NewDiscoveryChannelHandler(nil),
		),
		OpenAPI:        openapistatic.NewHandler(),
		AuthMiddleware: func(c *gin.Context) { c.Next() },
		RequirePermission: func(string) gin.HandlerFunc {
			return func(c *gin.Context) { c.Next() }
		},
	})
	return engine
}

// loadSpecPaths reads swagger.json — the exact bytes the package embeds —
// and returns its path items: path -> method -> operation.
func loadSpecPaths(t *testing.T) map[string]map[string]json.RawMessage {
	t.Helper()
	raw, err := os.ReadFile("swagger.json")
	if err != nil {
		t.Fatalf("read embedded spec source: %v", err)
	}
	var doc struct {
		Paths map[string]map[string]json.RawMessage `json:"paths"`
	}
	if err := json.Unmarshal(raw, &doc); err != nil {
		t.Fatalf("parse swagger.json: %v", err)
	}
	return doc.Paths
}

// loadSpecGETPaths returns the sorted spec paths that declare a GET
// operation.
func loadSpecGETPaths(t *testing.T) []string {
	t.Helper()
	var paths []string
	for path, ops := range loadSpecPaths(t) {
		if _, ok := ops["get"]; ok {
			paths = append(paths, path)
		}
	}
	sort.Strings(paths)
	return paths
}

// loadSpecWriteOperations returns every non-GET operation the spec
// advertises, sorted by method+path for deterministic failure output.
func loadSpecWriteOperations(t *testing.T) []specOperation {
	t.Helper()
	var ops []specOperation
	for path, methods := range loadSpecPaths(t) {
		for method := range methods {
			if method != strings.ToLower(http.MethodGet) {
				ops = append(ops, specOperation{method: strings.ToUpper(method), path: path})
			}
		}
	}
	slices.SortFunc(ops, func(a, b specOperation) int {
		return strings.Compare(a.String(), b.String())
	})
	return ops
}

// specOperationStrings renders operations as sorted "METHOD path" strings so
// sets of operations can be compared with diffSets.
func specOperationStrings(ops []specOperation) []string {
	strs := make([]string, 0, len(ops))
	for _, op := range ops {
		strs = append(strs, op.String())
	}
	sort.Strings(strs)
	return strs
}

// diffSets compares two string sets and returns the elements only in want
// (missing) and only in got (extra), so failure messages name the exact
// drifted endpoints. Pass sorted inputs for deterministic output.
func diffSets(want, got []string) (missing, extra []string) {
	wantSet := make(map[string]bool, len(want))
	for _, s := range want {
		wantSet[s] = true
	}
	gotSet := make(map[string]bool, len(got))
	for _, s := range got {
		gotSet[s] = true
		if !wantSet[s] {
			extra = append(extra, s)
		}
	}
	for _, s := range want {
		if !gotSet[s] {
			missing = append(missing, s)
		}
	}
	return missing, extra
}

// routeShape replaces every path-parameter segment — gin ":name" or swagger
// "{name}" — with "{}", so the two dialects can be compared by shape.
func routeShape(path string) string {
	segments := strings.Split(path, "/")
	for i, segment := range segments {
		if strings.HasPrefix(segment, ":") ||
			(strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}")) {
			segments[i] = "{}"
		}
	}
	return strings.Join(segments, "/")
}

// pathParamNames returns the ordered parameter names of a gin (":name") or
// swagger ("{name}") path.
func pathParamNames(path string) []string {
	var names []string
	for _, segment := range strings.Split(path, "/") {
		switch {
		case strings.HasPrefix(segment, ":"):
			names = append(names, strings.TrimPrefix(segment, ":"))
		case strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}"):
			names = append(names, strings.TrimSuffix(strings.TrimPrefix(segment, "{"), "}"))
		}
	}
	return names
}

// missingSpecGETRoutes returns the spec GET paths with no registered GET
// route in routes, comparing by shape so ":id" matches "{id}". It returns a
// list instead of failing so the negative control test can assert detection.
func missingSpecGETRoutes(specPaths []string, routes gin.RoutesInfo) []string {
	registered := make(map[string]bool, len(routes))
	for _, route := range routes {
		if route.Method == http.MethodGet {
			registered[routeShape(route.Path)] = true
		}
	}
	var missing []string
	for _, specPath := range specPaths {
		if !registered[routeShape(specPath)] {
			missing = append(missing, specPath)
		}
	}
	return missing
}

// missingSpecWriteRoutes returns the spec write operations with no
// registered route of the same method and path shape. It returns a list
// instead of failing so the negative control test can assert detection.
func missingSpecWriteRoutes(specOps []specOperation, routes gin.RoutesInfo) []string {
	registered := make(map[string]bool, len(routes))
	for _, route := range routes {
		registered[route.Method+" "+routeShape(route.Path)] = true
	}
	var missing []string
	for _, op := range specOps {
		if !registered[op.method+" "+routeShape(op.path)] {
			missing = append(missing, op.String())
		}
	}
	return missing
}

func TestOpenAPISpecRoutesAreRegistered(t *testing.T) {
	specPaths := loadSpecGETPaths(t)
	if missing, extra := diffSets(wantSpecGETPaths, specPaths); len(missing) > 0 || len(extra) > 0 {
		t.Fatalf("spec GET surface drifted, want exactly the %d pinned GET paths:\nmissing from spec: %s\nunexpected in spec: %s",
			len(wantSpecGETPaths), strings.Join(missing, ", "), strings.Join(extra, ", "))
	}

	engine := buildDriftTestEngine(t)
	if missing := missingSpecGETRoutes(specPaths, engine.Routes()); len(missing) > 0 {
		t.Fatalf("spec GET paths missing from registered routes: %s", strings.Join(missing, ", "))
	}

	// Shape equality is not enough: parameter names must match exactly, e.g.
	// the spec's /api/v1/vulnerabilities/{public_id} must be served by gin's
	// /api/v1/vulnerabilities/:public_id. gin enforces one parameter name per
	// route position, so one shape maps to exactly one name list.
	ginParams := make(map[string][]string, len(engine.Routes()))
	for _, route := range engine.Routes() {
		if route.Method == http.MethodGet {
			ginParams[routeShape(route.Path)] = pathParamNames(route.Path)
		}
	}
	for _, specPath := range specPaths {
		want := pathParamNames(specPath)
		if len(want) == 0 {
			continue
		}
		if got := ginParams[routeShape(specPath)]; !slices.Equal(got, want) {
			t.Fatalf("param names for %s: gin %v, spec %v", specPath, got, want)
		}
	}
}

// TestOpenAPISpecWriteRoutesAreRegistered pins the spec's write surface to
// the six vulnerability write operations and requires each of them to exist
// on the live engine — the same spec-to-router drift direction as the GET
// check above.
func TestOpenAPISpecWriteRoutesAreRegistered(t *testing.T) {
	specOps := loadSpecWriteOperations(t)
	if missing, extra := diffSets(specOperationStrings(wantSpecWriteOperations), specOperationStrings(specOps)); len(missing) > 0 || len(extra) > 0 {
		t.Fatalf("spec write surface drifted, want exactly the %d pinned write operations:\nmissing from spec: %s\nunexpected in spec: %s",
			len(wantSpecWriteOperations), strings.Join(missing, ", "), strings.Join(extra, ", "))
	}

	engine := buildDriftTestEngine(t)
	if missing := missingSpecWriteRoutes(wantSpecWriteOperations, engine.Routes()); len(missing) > 0 {
		t.Fatalf("spec write routes missing from registered routes: %s", strings.Join(missing, ", "))
	}
}

// TestOpenAPISpecTotals pins the document size: 38 paths and 41 operations
// (35 GET + 6 write). Any endpoint addition or removal must update these
// counts in the same change — that keeps the "spec changes require a
// deliberate test update" gate (plan §5).
func TestOpenAPISpecTotals(t *testing.T) {
	paths := loadSpecPaths(t)
	if len(paths) != 38 {
		t.Fatalf("spec exposes %d paths, want 38", len(paths))
	}
	operations := 0
	for _, methods := range paths {
		for method := range methods {
			switch method {
			case "get", "post", "put", "delete", "patch", "head", "options":
				operations++
			}
		}
	}
	if operations != 41 {
		t.Fatalf("spec exposes %d operations, want 41 (35 GET + 6 write)", operations)
	}
}

// TestDriftCheckDetectsRemovedRoute is the negative control: it proves the
// assertion above fails when a route the spec advertises disappears.
func TestDriftCheckDetectsRemovedRoute(t *testing.T) {
	engine := buildDriftTestEngine(t)
	specPaths := loadSpecGETPaths(t)

	const victim = "/api/v1/vulnerabilities/{public_id}"
	pruned := make(gin.RoutesInfo, 0, len(engine.Routes()))
	removed := ""
	for _, route := range engine.Routes() {
		if removed == "" && route.Method == http.MethodGet && routeShape(route.Path) == routeShape(victim) {
			removed = route.Path
			continue
		}
		pruned = append(pruned, route)
	}
	if removed == "" {
		t.Fatalf("setup: expected to find the gin route matching %s to remove", victim)
	}
	if missing := missingSpecGETRoutes(specPaths, pruned); !slices.Contains(missing, victim) {
		t.Fatalf("expected removal of %s to be detected, missing=%v", removed, missing)
	}
}

// TestDriftCheckDetectsRemovedWriteRoute is the negative control for the
// write check: it proves the write assertion fails when a write route the
// spec advertises disappears.
func TestDriftCheckDetectsRemovedWriteRoute(t *testing.T) {
	engine := buildDriftTestEngine(t)

	victim := specOperation{method: http.MethodPost, path: "/api/v1/vulnerabilities/{public_id}/transition"}
	pruned := make(gin.RoutesInfo, 0, len(engine.Routes()))
	removed := ""
	for _, route := range engine.Routes() {
		if removed == "" && route.Method == victim.method && routeShape(route.Path) == routeShape(victim.path) {
			removed = route.Method + " " + route.Path
			continue
		}
		pruned = append(pruned, route)
	}
	if removed == "" {
		t.Fatalf("setup: expected to find the gin route matching %s to remove", victim)
	}
	if missing := missingSpecWriteRoutes(wantSpecWriteOperations, pruned); !slices.Contains(missing, victim.String()) {
		t.Fatalf("expected removal of %s to be detected, missing=%v", removed, missing)
	}
}
