// Package client implements the REST client that the MCP server uses to
// talk to the Security Center backend API.
//
// The backend wraps every response in a JSON envelope
// ({code, message, data, timestamp}): business errors are returned as
// HTTP 200 with a non-zero body code, so this client always parses the
// envelope and only treats the HTTP status as authoritative for
// transport-level failures.
package client

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

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

const (
	// DefaultTimeout is the HTTP client timeout used by New.
	DefaultTimeout = 30 * time.Second

	// userAgent identifies this MCP server to the backend.
	userAgent = "security-center-mcp/0.1"

	// maxErrorSnippet caps how many response body bytes are quoted in
	// transport-level error messages.
	maxErrorSnippet = 200
)

// Client is a REST client for the Security Center backend API.
type Client struct {
	// BaseURL is the backend root, e.g. "http://127.0.0.1:8080".
	BaseURL string
	// APIKey authenticates requests via the X-API-Key header.
	APIKey string
	// HTTP is the underlying HTTP client; replace it to customize the
	// transport or timeout.
	HTTP *http.Client
}

// New builds a Client from the runtime configuration.
func New(cfg config.Config) *Client {
	return &Client{
		BaseURL: strings.TrimRight(cfg.BaseURL, "/"),
		APIKey:  cfg.APIKey,
		HTTP:    &http.Client{Timeout: DefaultTimeout},
	}
}

// envelope mirrors the backend response wrapper
// (security-center-backend/internal/pkg/response.APIResponse).
type envelope struct {
	Code      int             `json:"code"`
	Message   string          `json:"message"`
	Data      json.RawMessage `json:"data"`
	Timestamp int64           `json:"timestamp"`
}

// Get issues GET path with query and unmarshals the envelope's data into
// out. It is the bodyless special case of Do; see Do for the error
// contract.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
	return c.Do(ctx, http.MethodGet, path, query, nil, out)
}

// Do issues an HTTP request with the given method to path with query,
// and unmarshals the envelope's data into out. When body is non-nil it
// is serialized as JSON and sent with Content-Type: application/json;
// pass nil for bodyless requests. When data is null (or absent), out is
// left untouched. A non-zero envelope code is returned as *APIError
// (429 carries RetryAfter); a non-2xx HTTP status (i.e. no envelope) is
// returned as a plain error. Every error names the method and path, so
// failures identify exactly which request went out.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body any, out any) error {
	u, err := url.Parse(c.BaseURL + "/" + strings.TrimLeft(path, "/"))
	if err != nil {
		return fmt.Errorf("security center API: build URL for %q: %w", path, err)
	}
	if len(query) > 0 {
		q := u.Query()
		for key, values := range query {
			for _, v := range values {
				q.Add(key, v)
			}
		}
		u.RawQuery = q.Encode()
	}

	var reqBody io.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return fmt.Errorf("security center API: %s %s: encode request body: %w", method, path, err)
		}
		reqBody = bytes.NewReader(raw)
	}

	req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
	if err != nil {
		return fmt.Errorf("security center API: build request for %q: %w", path, err)
	}
	req.Header.Set("X-API-Key", c.APIKey)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("User-Agent", userAgent)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return fmt.Errorf("security center API: %s %s: %w", method, path, err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("security center API: %s %s: read body: %w", method, path, err)
	}

	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return fmt.Errorf("security center API: %s %s: unexpected HTTP status %s: %s",
			method, path, resp.Status, snippet(respBody))
	}

	var env envelope
	if err := json.Unmarshal(respBody, &env); err != nil {
		return fmt.Errorf("security center API: %s %s: invalid response envelope: %w", method, path, err)
	}

	if env.Code != 0 {
		return &APIError{
			Code:       env.Code,
			Message:    env.Message,
			HTTPStatus: resp.StatusCode,
			RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After")),
		}
	}

	data := env.Data
	if out == nil || len(data) == 0 || string(data) == "null" {
		return nil
	}
	if err := json.Unmarshal(data, out); err != nil {
		return fmt.Errorf("security center API: %s %s: decode data: %w", method, path, err)
	}
	return nil
}

// PutRaw uploads raw bytes with an HTTP PUT to a presigned URL — step 2 of
// the attachment flow (presign via POST /uploads, then this, then confirm
// via POST /uploads/confirm). Unlike Do it bypasses the API envelope and
// never sends the X-API-Key header: the presigned URL itself is the
// credential, so leaking the API key to the object store would be a
// security bug. contentType must be the value declared when the presign
// was requested (S3 rejects the upload if it differs from the signed
// value). A non-2xx status is returned as a plain error naming the URL
// host and status; the signed query string is never quoted so credentials
// cannot end up in logs.
func (c *Client) PutRaw(ctx context.Context, rawURL string, body []byte, contentType string) error {
	u, err := url.Parse(rawURL)
	if err != nil {
		return fmt.Errorf("security center upload: parse presigned URL: %w", err)
	}
	host := u.Host

	req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, bytes.NewReader(body))
	if err != nil {
		return fmt.Errorf("security center upload: build request for %s: %w", host, err)
	}
	req.Header.Set("User-Agent", userAgent)
	if contentType != "" {
		req.Header.Set("Content-Type", contentType)
	}

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return fmt.Errorf("security center upload: PUT %s: %w", host, err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("security center upload: PUT %s: read body: %w", host, err)
	}

	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		return fmt.Errorf("security center upload: PUT %s: unexpected HTTP status %s: %s",
			host, resp.Status, snippet(respBody))
	}
	return nil
}

// snippet returns at most maxErrorSnippet bytes of body for error messages.
func snippet(body []byte) string {
	if len(body) > maxErrorSnippet {
		return string(body[:maxErrorSnippet]) + "..."
	}
	return string(body)
}

// parseRetryAfter parses a Retry-After header value in seconds (the only
// form the backend emits). Empty or unparseable values yield 0.
func parseRetryAfter(v string) time.Duration {
	secs, err := strconv.Atoi(strings.TrimSpace(v))
	if err != nil || secs < 0 {
		return 0
	}
	return time.Duration(secs) * time.Second
}

// APIError is a business error reported through the backend envelope
// (HTTP 200 with a non-zero body code). Code mirrors HTTP status
// semantics, so the Is* helpers let the tools layer map it to
// LLM-readable messages.
type APIError struct {
	// Code is the envelope code (401/403/404/429/...).
	Code int
	// Message is the envelope message.
	Message string
	// HTTPStatus is the transport status (usually 200, since the backend
	// wraps errors in the envelope).
	HTTPStatus int
	// RetryAfter is parsed from the Retry-After header (set on 429).
	RetryAfter time.Duration
}

func (e *APIError) Error() string {
	if e.RetryAfter > 0 {
		return fmt.Sprintf("security center API error: code=%d message=%q (http %d, retry after %s)",
			e.Code, e.Message, e.HTTPStatus, e.RetryAfter)
	}
	return fmt.Sprintf("security center API error: code=%d message=%q (http %d)",
		e.Code, e.Message, e.HTTPStatus)
}

// IsUnauthorized reports an invalid or missing API key (code 401).
func (e *APIError) IsUnauthorized() bool { return e.Code == http.StatusUnauthorized }

// IsForbidden reports a missing permission (code 403).
func (e *APIError) IsForbidden() bool { return e.Code == http.StatusForbidden }

// IsNotFound reports a missing resource (code 404).
func (e *APIError) IsNotFound() bool { return e.Code == http.StatusNotFound }

// IsRateLimited reports quota exhaustion (code 429); see RetryAfter.
func (e *APIError) IsRateLimited() bool { return e.Code == http.StatusTooManyRequests }
