package tools

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

	"security-center-mcp/internal/client"
)

// recordedRequest captures what the mock backend received.
type recordedRequest struct {
	method      string
	path        string
	query       url.Values
	contentType string
	body        []byte
}

// newMockClient starts an httptest backend that records each request
// and delegates the response to handler, and returns a client pointed
// at it plus the recorded requests.
func newMockClient(t *testing.T, handler http.HandlerFunc) (*client.Client, *[]recordedRequest) {
	t.Helper()
	var reqs []recordedRequest
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			t.Errorf("mock backend: read request body: %v", err)
		}
		reqs = append(reqs, recordedRequest{
			method:      r.Method,
			path:        r.URL.Path,
			query:       r.URL.Query(),
			contentType: r.Header.Get("Content-Type"),
			body:        body,
		})
		handler(w, r)
	}))
	t.Cleanup(srv.Close)
	return &client.Client{BaseURL: srv.URL, APIKey: "test-key", HTTP: srv.Client()}, &reqs
}

// respondData writes a success envelope carrying data.
func respondData(w http.ResponseWriter, data any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"code": 0, "message": "ok", "data": data, "timestamp": 0,
	})
}

// respondError writes a business-error envelope (HTTP 200, non-zero
// code), mirroring the backend contract.
func respondError(w http.ResponseWriter, code int, message string) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"code": code, "message": message, "data": nil, "timestamp": 0,
	})
}

func TestResolveEndpointPathParam(t *testing.T) {
	ep := EndpointRef{Method: "get", Path: "/api/v1/caasm/assets/{id}", ParamMap: paramMap("id")}

	path, query, err := resolveEndpoint(ep, map[string]any{"id": float64(42)})
	if err != nil {
		t.Fatalf("resolveEndpoint: %v", err)
	}
	if path != "/api/v1/caasm/assets/42" {
		t.Errorf("path = %q, want /api/v1/caasm/assets/42", path)
	}
	if len(query) != 0 {
		t.Errorf("query = %v, want empty (id consumed by path)", query)
	}
}

func TestResolveEndpointPathParamMissing(t *testing.T) {
	ep := EndpointRef{Method: "get", Path: "/api/v1/caasm/assets/{id}", ParamMap: paramMap("id")}

	_, _, err := resolveEndpoint(ep, map[string]any{})
	if err == nil {
		t.Fatal("resolveEndpoint with missing id: want error, got nil")
	}
	var argErr *ArgError
	if !errors.As(err, &argErr) {
		t.Errorf("error type = %T, want *ArgError", err)
	}
	if !strings.Contains(err.Error(), "id") {
		t.Errorf("error %q should name the missing argument", err)
	}
}

func TestResolveEndpointPathParamWrongType(t *testing.T) {
	ep := EndpointRef{Method: "get", Path: "/api/v1/caasm/assets/{id}", ParamMap: paramMap("id")}

	if _, _, err := resolveEndpoint(ep, map[string]any{"id": []any{float64(1)}}); err == nil {
		t.Fatal("resolveEndpoint with array path param: want error, got nil")
	}
}

func TestResolveEndpointQuerySerialization(t *testing.T) {
	ep := EndpointRef{
		Method: "get",
		Path:   "/api/v1/caasm/ports",
		ParamMap: paramMap(
			"page", "port", "protocol", "has_alert", "tags"),
	}

	_, query, err := resolveEndpoint(ep, map[string]any{
		"page":      float64(2), // JSON-decoded number
		"port":      int(3306),  // direct Go int
		"protocol":  "tcp",
		"has_alert": true,
		"tags":      []any{"web", "exposed"}, // arrays join with commas
	})
	if err != nil {
		t.Fatalf("resolveEndpoint: %v", err)
	}
	want := url.Values{
		"page":      {"2"},
		"port":      {"3306"},
		"protocol":  {"tcp"},
		"has_alert": {"true"},
		"tags":      {"web,exposed"},
	}
	for key, vals := range want {
		got := query[key]
		if len(got) != len(vals) || got[0] != vals[0] {
			t.Errorf("query[%q] = %v, want %v", key, got, vals)
		}
	}

	// Absent arguments are omitted, not serialized as empty values.
	_, query, err = resolveEndpoint(ep, map[string]any{"protocol": "udp"})
	if err != nil {
		t.Fatalf("resolveEndpoint: %v", err)
	}
	if len(query) != 1 || query.Get("protocol") != "udp" {
		t.Errorf("query = %v, want only protocol=udp", query)
	}
}

func TestDispatchAPIErrorMapping(t *testing.T) {
	cases := []struct {
		name        string
		code        int
		message     string
		retryAfter  string // Retry-After header, "" = none
		wantRetries bool
	}{
		{"unauthorized", 401, "invalid api key", "", false},
		{"forbidden", 403, "missing permission", "", false},
		{"not found", 404, "asset not found", "", false},
		{"rate limited", 429, "quota exceeded", "30", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			c, _ := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
				if tc.retryAfter != "" {
					w.Header().Set("Retry-After", tc.retryAfter)
				}
				respondError(w, tc.code, tc.message)
			})

			res, err := Dispatch(context.Background(), c, "list_high_risk_ports", nil)
			if err != nil {
				t.Fatalf("Dispatch returned Go error, want structured result: %v", err)
			}
			errObj, ok := res.(map[string]any)["error"].(map[string]any)
			if !ok {
				t.Fatalf("result = %#v, want {\"error\": {...}}", res)
			}
			if errObj["code"] != tc.code {
				t.Errorf("error.code = %v, want %d", errObj["code"], tc.code)
			}
			if errObj["message"] != tc.message {
				t.Errorf("error.message = %v, want %q", errObj["message"], tc.message)
			}
			_, hasRetry := errObj["retry_after"]
			if hasRetry != tc.wantRetries {
				t.Errorf("retry_after present = %v, want %v (obj: %v)", hasRetry, tc.wantRetries, errObj)
			}
			if tc.wantRetries && errObj["retry_after"] != int64(30) {
				t.Errorf("retry_after = %v, want 30", errObj["retry_after"])
			}
		})
	}
}

func TestDispatchTransportErrorMapping(t *testing.T) {
	c, _ := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadGateway)
		_, _ = w.Write([]byte("bad gateway"))
	})

	res, err := Dispatch(context.Background(), c, "list_high_risk_ports", nil)
	if err != nil {
		t.Fatalf("Dispatch returned Go error, want structured result: %v", err)
	}
	errObj, ok := res.(map[string]any)["error"].(map[string]any)
	if !ok {
		t.Fatalf("result = %#v, want {\"error\": {...}}", res)
	}
	if errObj["code"] != "upstream" {
		t.Errorf("error.code = %v, want \"upstream\"", errObj["code"])
	}
	if msg, _ := errObj["message"].(string); !strings.Contains(msg, "502") {
		t.Errorf("error.message = %q, want it to mention the HTTP status", msg)
	}
}

// TestDispatchUnknownToolFallback covers the callVulnTool fallback
// contract: names outside the 10 CAASM tools are offered to
// callVulnTool, and only handled=false yields an unknown-tool error.
// Since P4-T5 landed, vuln.go's init() has replaced the stub with the
// real dispatchVuln, so the vuln tool names report handled=true; that
// positive path is covered end-to-end in vuln_test.go
// (TestDispatchRoutesVulnTools). Only genuinely unknown names assert
// the unknown-tool error here.
func TestDispatchUnknownToolFallback(t *testing.T) {
	c, reqs := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
		respondData(w, map[string]any{"unexpected": true})
	})

	for _, name := range []string{"no_such_tool", "definitely_unknown"} {
		_, err := Dispatch(context.Background(), c, name, nil)
		if err == nil {
			t.Errorf("Dispatch(%q): want unknown-tool error, got nil", name)
			continue
		}
		if !strings.Contains(err.Error(), "unknown tool") {
			t.Errorf("Dispatch(%q) error = %q, want it to say \"unknown tool\"", name, err)
		}
	}
	if len(*reqs) != 0 {
		t.Errorf("fallback issued %d backend requests, want 0", len(*reqs))
	}
}

// TestCallEndpointBodyMapping asserts a POST endpoint sends the
// BodyMap-mapped arguments as a JSON body with native JSON types, the
// ParamMap-mapped ones as path/query parameters, and drops arguments
// covered by neither mapping.
func TestCallEndpointBodyMapping(t *testing.T) {
	c, reqs := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
		respondData(w, map[string]any{"id": "c1"})
	})
	ep := EndpointRef{
		Method:   "post",
		Path:     "/api/v1/vulnerabilities/{public_id}/comments",
		ParamMap: paramMap("public_id", "page"),
		BodyMap:  bodyMap("content", "is_internal", "attachments"),
	}

	data, err := callEndpoint(context.Background(), c, ep, map[string]any{
		"public_id":   "VULN-1",
		"page":        float64(2), // query parameter alongside the body
		"content":     "hello 世界",
		"is_internal": true,
		"attachments": []any{"a1", "a2"}, // arrays pass through natively
		"dropped":     "x",               // in no mapping: must go nowhere
	})
	if err != nil {
		t.Fatalf("callEndpoint: %v", err)
	}
	if data == nil {
		t.Fatal("callEndpoint returned nil data, want envelope data")
	}

	if len(*reqs) != 1 {
		t.Fatalf("backend received %d requests, want 1", len(*reqs))
	}
	req := (*reqs)[0]
	if req.method != http.MethodPost {
		t.Errorf("method = %q, want POST", req.method)
	}
	if req.path != "/api/v1/vulnerabilities/VULN-1/comments" {
		t.Errorf("path = %q, want /api/v1/vulnerabilities/VULN-1/comments", req.path)
	}
	if got := req.query.Encode(); got != "page=2" {
		t.Errorf("query = %q, want only page=2 (body fields must not leak into query)", got)
	}
	if req.contentType != "application/json" {
		t.Errorf("Content-Type = %q, want application/json", req.contentType)
	}

	var body map[string]any
	if err := json.Unmarshal(req.body, &body); err != nil {
		t.Fatalf("request body is not JSON: %v (%q)", err, req.body)
	}
	want := map[string]any{
		"content":     "hello 世界",
		"is_internal": true,
		"attachments": []any{"a1", "a2"},
	}
	raw, _ := json.Marshal(body)
	wantRaw, _ := json.Marshal(want)
	if string(raw) != string(wantRaw) {
		t.Errorf("body = %s, want %s", raw, wantRaw)
	}
	if _, ok := body["dropped"]; ok {
		t.Errorf("unmapped argument \"dropped\" leaked into body: %s", raw)
	}
	if strings.Contains(string(req.body), "VULN-1") {
		t.Errorf("path parameter leaked into body: %s", req.body)
	}
}

// TestCallEndpointGetNeverSendsBody asserts GET endpoints stay bodyless
// even when arguments are present — including a (mistakenly declared)
// BodyMap, which must not turn into a request body.
func TestCallEndpointGetNeverSendsBody(t *testing.T) {
	c, reqs := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
		respondData(w, map[string]any{"ok": true})
	})
	ep := EndpointRef{
		Method:   "get",
		Path:     "/api/v1/things",
		ParamMap: paramMap("q", "note"),
		BodyMap:  bodyMap("note"), // registry bug the tests forbid; call path must stay safe
	}

	if _, err := callEndpoint(context.Background(), c, ep, map[string]any{
		"q":    "x",
		"note": "y",
	}); err != nil {
		t.Fatalf("callEndpoint: %v", err)
	}

	if len(*reqs) != 1 {
		t.Fatalf("backend received %d requests, want 1", len(*reqs))
	}
	req := (*reqs)[0]
	if req.method != http.MethodGet {
		t.Errorf("method = %q, want GET", req.method)
	}
	if len(req.body) != 0 {
		t.Errorf("GET request carried a body: %q", req.body)
	}
	if req.contentType != "" {
		t.Errorf("GET request carried Content-Type %q, want none", req.contentType)
	}
	if got := req.query.Encode(); got != "q=x" {
		t.Errorf("query = %q, want only q=x (BodyMap-mapped argument must not reach the query)", got)
	}
}

// TestCallEndpointWriteWithoutBodyArgs asserts a POST endpoint with a
// BodyMap but no mapped argument present stays bodyless (nil body, no
// Content-Type) instead of sending an empty JSON object.
func TestCallEndpointWriteWithoutBodyArgs(t *testing.T) {
	c, reqs := newMockClient(t, func(w http.ResponseWriter, r *http.Request) {
		respondData(w, map[string]any{"ok": true})
	})
	ep := EndpointRef{
		Method:   "post",
		Path:     "/api/v1/vulnerabilities/{public_id}/transition",
		ParamMap: paramMap("public_id"),
		BodyMap:  bodyMap("action", "comment"),
	}

	if _, err := callEndpoint(context.Background(), c, ep, map[string]any{
		"public_id": "VULN-9",
	}); err != nil {
		t.Fatalf("callEndpoint: %v", err)
	}

	if len(*reqs) != 1 {
		t.Fatalf("backend received %d requests, want 1", len(*reqs))
	}
	req := (*reqs)[0]
	if req.method != http.MethodPost {
		t.Errorf("method = %q, want POST", req.method)
	}
	if len(req.body) != 0 {
		t.Errorf("POST without body arguments carried a body: %q", req.body)
	}
	if req.contentType != "" {
		t.Errorf("POST without body carried Content-Type %q, want none", req.contentType)
	}
}

// TestUnknownArgs asserts unknownArgs flags exactly the arguments the
// tool schema does not declare (dispatch-only arguments like view are
// declared, so they never show up).
func TestUnknownArgs(t *testing.T) {
	tool := Tool{
		Name: "t",
		InputSchema: objectSchema(map[string]Property{
			"q":    strProp("关键词"),
			"view": {Type: "string", Description: "调度参数"},
		}),
	}

	got := unknownArgs(tool, map[string]any{
		"q":     "x",
		"view":  "stats",
		"typo":  1,
		"other": "y",
	})
	if len(got) != 2 || got[0] != "other" || got[1] != "typo" {
		t.Errorf("unknownArgs = %v, want [other typo] (sorted)", got)
	}

	if got := unknownArgs(tool, nil); len(got) != 0 {
		t.Errorf("unknownArgs(nil args) = %v, want empty", got)
	}
	if got := unknownArgs(Tool{Name: "no-schema"}, map[string]any{"x": 1}); len(got) != 0 {
		t.Errorf("unknownArgs(nil schema) = %v, want empty", got)
	}
}
