package tools

import (
	"context"
	"errors"
	"fmt"
	"math"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"

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

// ArgError reports a missing, mistyped or mutually exclusive tool
// argument. Unlike backend/transport failures it is returned to the
// caller as a plain Go error (the protocol layer in P4-T6 decides how
// to surface it), because it is a call-shape problem, not an upstream
// one.
type ArgError struct{ Msg string }

func (e *ArgError) Error() string { return e.Msg }

// callVulnTool dispatches the vulnerability tools (the four read tools
// search_vulnerabilities / get_vulnerability /
// list_vuln_lookup_options / get_vuln_tags_stats, and since P3-T4 the
// four write tools submit_vulnerability / update_vulnerability /
// transition_vulnerability / add_vulnerability_comment). It is owned
// by vuln.go: vuln.go's init() replaces this package-level variable
// with the real implementation. Until then the stub reports every
// name as unhandled, so Dispatch falls through to the unknown-tool
// error.
//
// Contract (do not change the signature): name/args are passed
// through verbatim; handled=false means "not a vuln tool", in which
// case data and err are ignored.
var callVulnTool = func(ctx context.Context, c *client.Client, name string, args map[string]any) (data any, handled bool, err error) {
	return nil, false, nil
}

// Dispatch executes one MCP tools/call request (plan §4.4): it routes
// by tool name, maps arguments to backend query/path parameters,
// calls the backend through the client, strips the response envelope
// (only data is returned, no further field trimming) and maps errors
// to a uniform shape.
//
// Routing: whoami is answered first (it belongs to neither tool
// family); the 10 CAASM tools are executed here (see caasm.go);
// every other name — the 8 vuln tools (P4-T5 read + P3-T4 write), or
// a name that does not exist at all — falls back to callVulnTool, and
// only if that reports handled=false is an unknown-tool error
// returned.
//
// Error mapping: argument problems are returned as *ArgError; backend
// business errors (*client.APIError) and transport failures are
// returned as structured results (not Go errors) so the LLM can read
// them:
//
//	{"error": {"code": 401|403|404|429|..., "message": "...", "retry_after": <secs>}}
//	{"error": {"code": "upstream", "message": "..."}}
func Dispatch(ctx context.Context, c *client.Client, name string, args map[string]any) (any, error) {
	if tool, ok := findTool(name); ok {
		logUnknownArgs(tool, args)
	}
	// whoami 是身份自描述工具，既非 CAASM 也非 vuln，在此直接分发
	// （固定 GET、无参数映射，见 whoami.go）。
	if name == "whoami" {
		return callWhoami(ctx, c)
	}
	if caasmToolNames[name] {
		tool, ok := findTool(name)
		if !ok {
			return nil, fmt.Errorf("tools: CAASM tool %q missing from registry", name)
		}
		data, err := callCAASMTool(ctx, c, tool, args)
		if err != nil {
			var argErr *ArgError
			if errors.As(err, &argErr) {
				return nil, argErr
			}
			return errorResult(err), nil
		}
		return data, nil
	}

	data, handled, err := callVulnTool(ctx, c, name, args)
	if err != nil {
		return nil, err
	}
	if handled {
		return data, nil
	}
	return nil, fmt.Errorf("tools: unknown tool %q", name)
}

// findTool looks up a registered tool by name.
func findTool(name string) (Tool, bool) {
	for _, tool := range Registry() {
		if tool.Name == name {
			return tool, true
		}
	}
	return Tool{}, false
}

// errorResult maps a backend/transport failure to the structured,
// LLM-readable error result. *client.APIError keeps its numeric code
// (401/403/404/429/...) and carries retry_after (seconds) when the
// backend asked for a backoff; anything else is a transport-level
// failure reported with code "upstream".
func errorResult(err error) any {
	var apiErr *client.APIError
	if errors.As(err, &apiErr) {
		e := map[string]any{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		}
		if apiErr.RetryAfter > 0 {
			e["retry_after"] = int64(apiErr.RetryAfter / time.Second)
		}
		return map[string]any{"error": e}
	}
	return map[string]any{"error": map[string]any{
		"code":    "upstream",
		"message": err.Error(),
	}}
}

// resolveEndpoint fills the endpoint's path placeholders from args and
// maps the remaining arguments to query parameters per ParamMap.
//
// Path parameters: every {placeholder} in Path must be fillable from
// the argument mapped to that backend parameter — a missing value or a
// non-scalar one yields an *ArgError. Query parameters: only arguments
// actually present in args are serialized; scalars follow their JSON
// type and arrays are joined with commas (matching the backend's
// binding convention). Arguments mapped by BodyMap never reach the
// query string — they belong to the JSON body (see resolveBody).
func resolveEndpoint(ep EndpointRef, args map[string]any) (path string, query url.Values, err error) {
	path = ep.Path
	for {
		open := strings.IndexByte(path, '{')
		if open < 0 {
			break
		}
		closing := strings.IndexByte(path[open:], '}')
		if closing < 0 {
			return "", nil, fmt.Errorf("tools: endpoint %s has unbalanced path placeholder", ep.Path)
		}
		param := path[open+1 : open+closing]
		argName, ok := argForBackendParam(ep, param)
		if !ok {
			return "", nil, fmt.Errorf("tools: endpoint %s path parameter %q not covered by ParamMap", ep.Path, param)
		}
		value, ok := args[argName]
		if !ok || value == nil {
			return "", nil, &ArgError{Msg: fmt.Sprintf("缺少必需参数 %q（用于路径参数 %s）", argName, param)}
		}
		s, err := stringifyScalar(value)
		if err != nil {
			return "", nil, &ArgError{Msg: fmt.Sprintf("参数 %q 类型错误：%v", argName, err)}
		}
		path = path[:open] + url.PathEscape(s) + path[open+closing+1:]
	}

	query = url.Values{}
	for argName, backendParam := range ep.ParamMap {
		if strings.Contains(ep.Path, "{"+backendParam+"}") {
			continue // already substituted into the path
		}
		if _, inBody := ep.BodyMap[argName]; inBody {
			continue // carried in the JSON body instead (registry tests forbid the overlap)
		}
		value, ok := args[argName]
		if !ok || value == nil {
			continue
		}
		s, err := stringifyValue(value)
		if err != nil {
			return "", nil, &ArgError{Msg: fmt.Sprintf("参数 %q 类型错误：%v", argName, err)}
		}
		query.Add(backendParam, s)
	}
	return path, query, nil
}

// callEndpoint resolves the endpoint against args and issues the
// request through the client per ep.Method: GET sends only path and
// query (never a body, even if a BodyMap were declared by mistake);
// POST/PUT additionally carry a JSON body built from the endpoint's
// BodyMap, staying bodyless when no mapped argument is present. It
// returns the envelope's data decoded as any (nil when data is null).
func callEndpoint(ctx context.Context, c *client.Client, ep EndpointRef, args map[string]any) (any, error) {
	path, query, err := resolveEndpoint(ep, args)
	if err != nil {
		return nil, err
	}
	method := strings.ToUpper(ep.Method)
	if method == "" {
		method = http.MethodGet
	}
	// body stays a nil any unless this is a non-GET endpoint with at
	// least one mapped argument present — a typed nil map would not
	// compare equal to nil inside client.Do and would be sent as the
	// JSON literal "null".
	var body any
	if method != http.MethodGet {
		if b := resolveBody(ep, args); b != nil {
			body = b
		}
	}
	var data any
	if err := c.Do(ctx, method, path, query, body, &data); err != nil {
		return nil, err
	}
	return data, nil
}

// getData is the GET spelling of callEndpoint, kept for the existing
// read-only callers (their endpoints are all Method "get", so behavior
// is unchanged). Write-tool callers use callEndpoint directly.
func getData(ctx context.Context, c *client.Client, ep EndpointRef, args map[string]any) (any, error) {
	return callEndpoint(ctx, c, ep, args)
}

// resolveBody builds the JSON request body from the endpoint's BodyMap:
// every mapped argument present in args is carried under its body field
// name with its native JSON type (no stringification — the backend
// validates types). It returns nil when no mapped argument is present,
// so the request stays bodyless and client.Do sends no Content-Type.
func resolveBody(ep EndpointRef, args map[string]any) map[string]any {
	var body map[string]any
	for argName, field := range ep.BodyMap {
		value, ok := args[argName]
		if !ok || value == nil {
			continue
		}
		if body == nil {
			body = make(map[string]any, len(ep.BodyMap))
		}
		body[field] = value
	}
	return body
}

// unknownArgs returns the names of arguments not declared in the tool's
// InputSchema. They are silently dropped by every endpoint mapping, so
// a typo'd or hallucinated argument would otherwise be invisible.
// Dispatch-only arguments (view/kind) are declared in the schema, so
// they never show up here.
func unknownArgs(tool Tool, args map[string]any) []string {
	if tool.InputSchema == nil {
		return nil
	}
	var unknown []string
	for argName := range args {
		if _, ok := tool.InputSchema.Properties[argName]; !ok {
			unknown = append(unknown, argName)
		}
	}
	sort.Strings(unknown)
	return unknown
}

// logUnknownArgs writes one stderr line naming the undeclared arguments
// a call carries (module convention: stdout is reserved for JSON-RPC
// frames). Names only — values may be sensitive and are never logged.
func logUnknownArgs(tool Tool, args map[string]any) {
	if unknown := unknownArgs(tool, args); len(unknown) > 0 {
		fmt.Fprintf(os.Stderr, "security-center-mcp: tools: %s: ignoring undeclared arguments: %s\n",
			tool.Name, strings.Join(unknown, ", "))
	}
}

// argForBackendParam reverse-looks-up the tool argument mapped to the
// given backend parameter name.
func argForBackendParam(ep EndpointRef, backendParam string) (string, bool) {
	for argName, mapped := range ep.ParamMap {
		if mapped == backendParam {
			return argName, true
		}
	}
	return "", false
}

// stringifyValue serializes a query argument: scalars per
// stringifyScalar, arrays as comma-joined scalars.
func stringifyValue(v any) (string, error) {
	if arr, ok := v.([]any); ok {
		parts := make([]string, 0, len(arr))
		for _, item := range arr {
			s, err := stringifyScalar(item)
			if err != nil {
				return "", err
			}
			parts = append(parts, s)
		}
		return strings.Join(parts, ","), nil
	}
	return stringifyScalar(v)
}

// stringifyScalar renders one scalar argument value. JSON decoding
// yields float64 for every number; whole numbers are rendered without
// a fraction so integer backend parameters accept them. Plain Go ints
// are accepted too, for direct callers.
func stringifyScalar(v any) (string, error) {
	switch value := v.(type) {
	case string:
		return value, nil
	case bool:
		return strconv.FormatBool(value), nil
	case float64:
		if value == math.Trunc(value) && !math.IsInf(value, 0) {
			return strconv.FormatInt(int64(value), 10), nil
		}
		return strconv.FormatFloat(value, 'f', -1, 64), nil
	case int:
		return strconv.Itoa(value), nil
	case int64:
		return strconv.FormatInt(value, 10), nil
	default:
		return "", fmt.Errorf("unsupported value type %T", v)
	}
}
