// Cryptanio 1.0.0 62 / public API v1
// Клиент публичного Cryptanio API /v1. Версия файла ставится сборкой сайта.
package cryptanio

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"time"
)

const maxJSONBytes = 8 * 1024 * 1024

// APIError содержит HTTP-статус, публичный код и Retry-After без автоматического повтора.
type APIError struct {
	Status     int
	Code       string
	Message    string
	RetryAfter string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("Cryptanio API (%d, %s): %s", e.Status, e.Code, e.Message)
}

// Client безопасно используется несколькими goroutine. Ключ не отправляется в health/chains.
type Client struct {
	apiKey string
	origin string
	http   *http.Client
}

// New использует production и 30 секунд при пустом origin/нулевом timeout.
// HTTP разрешён только для loopback; timeout ограничен пятью минутами, включая SSE.
func New(apiKey, baseOrigin string, timeout time.Duration) (*Client, error) {
	if baseOrigin == "" {
		baseOrigin = "https://api.cryptanio.com"
	}
	u, err := url.Parse(baseOrigin)
	if err != nil || u.Hostname() == "" || u.User != nil || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || strings.Contains(baseOrigin, "#") || (u.Path != "" && u.Path != "/") || (u.RawPath != "" && u.RawPath != "/") {
		return nil, &APIError{Code: "invalid_origin", Message: "baseOrigin must be an origin without credentials, path, query or fragment"}
	}
	loopback := strings.EqualFold(u.Hostname(), "localhost")
	if ip := net.ParseIP(u.Hostname()); ip != nil {
		loopback = ip.IsLoopback()
	}
	if u.Scheme != "https" && !(u.Scheme == "http" && loopback) {
		return nil, &APIError{Code: "invalid_origin", Message: "HTTPS is required outside loopback"}
	}
	if strings.IndexFunc(apiKey, func(r rune) bool { return r <= 32 || r == 127 }) >= 0 {
		return nil, &APIError{Code: "invalid_key", Message: "API key must not contain whitespace or control characters"}
	}
	if timeout == 0 {
		timeout = 30 * time.Second
	}
	if timeout < 0 || timeout > 5*time.Minute {
		return nil, &APIError{Code: "invalid_timeout", Message: "timeout must be greater than zero and at most 300 seconds"}
	}
	// Не переиспользуем соединения: net/http может автоматически повторять GET на stale connection.
	transport := &http.Transport{Proxy: http.ProxyFromEnvironment, DisableKeepAlives: true, TLSHandshakeTimeout: 10 * time.Second}
	return &Client{apiKey: apiKey, origin: strings.TrimSuffix(baseOrigin, "/"), http: &http.Client{
		Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}}, nil
}

func (c *Client) failure(status int, code, message, retryAfter string) *APIError {
	redact := func(value string) string {
		if c.apiKey == "" {
			return value
		}
		return strings.ReplaceAll(value, c.apiKey, "[REDACTED]")
	}
	return &APIError{status, redact(code), redact(message), redact(retryAfter)}
}

func (c *Client) send(ctx context.Context, method, path, query string, body map[string]any, idempotencyKey string, public, stream bool) (*http.Response, error) {
	if !public && c.apiKey == "" {
		return nil, c.failure(0, "missing_key", "An API key is required", "")
	}
	var reader io.Reader
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return nil, c.failure(0, "invalid_body", "Request body cannot be encoded as JSON", "")
		}
		reader = bytes.NewReader(encoded)
	}
	target := c.origin + path
	if query != "" {
		target += "?" + query
	}
	req, err := http.NewRequestWithContext(ctx, method, target, reader)
	if err != nil {
		return nil, c.failure(0, "invalid_request", "Request could not be created", "")
	}
	req.Header.Set("Accept", "application/json")
	if stream {
		req.Header.Set("Accept", "text/event-stream")
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if !public {
		req.Header.Set("Authorization", "Bearer "+c.apiKey)
	}
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		code := "request_failed"
		var netErr net.Error
		if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
			code = "timeout"
		}
		return nil, c.failure(0, code, "Request failed; the outcome of a write may be unknown", "")
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		defer resp.Body.Close()
		data, _ := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes+1))
		var envelope struct {
			Error struct {
				Code    string `json:"code"`
				Message string `json:"message"`
			} `json:"error"`
		}
		_ = json.Unmarshal(data, &envelope)
		code, message := envelope.Error.Code, envelope.Error.Message
		if code == "" {
			code = "http_error"
		}
		if message == "" {
			message = "The server returned HTTP " + fmt.Sprint(resp.StatusCode)
		}
		return nil, c.failure(resp.StatusCode, code, message, resp.Header.Get("Retry-After"))
	}
	return resp, nil
}

func (c *Client) request(ctx context.Context, method, path, query string, body map[string]any, key string, public bool) (map[string]any, error) {
	resp, err := c.send(ctx, method, path, query, body, key, public, false)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes+1))
	if err != nil {
		return nil, c.failure(resp.StatusCode, "invalid_response", "Response could not be read", "")
	}
	if len(data) > maxJSONBytes {
		return nil, c.failure(resp.StatusCode, "response_too_large", "JSON response exceeds 8 MiB", "")
	}
	if resp.StatusCode == http.StatusNoContent && len(data) == 0 {
		return nil, nil
	}
	decoder := json.NewDecoder(bytes.NewReader(data))
	decoder.UseNumber()
	var result map[string]any
	if err := decoder.Decode(&result); err != nil || result == nil {
		return nil, c.failure(resp.StatusCode, "invalid_response", "Expected a JSON object", "")
	}
	var trailing any
	if decoder.Decode(&trailing) != io.EOF {
		return nil, c.failure(resp.StatusCode, "invalid_response", "Unexpected data after JSON response", "")
	}
	return result, nil
}

func resourceID(id string) (string, error) {
	if strings.TrimSpace(id) == "" || id == "." || id == ".." {
		return "", &APIError{Code: "invalid_id", Message: "Resource ID must be a nonempty string other than a dot segment"}
	}
	return url.PathEscape(id), nil
}

func filters(values map[string]any, allowed string) (string, error) {
	query := url.Values{}
	for name, value := range values {
		if !strings.Contains(" "+allowed+" ", " "+name+" ") || name == "" {
			return "", &APIError{Code: "invalid_filter", Message: "Unsupported list filter"}
		}
		if value == nil {
			continue
		}
		if name == "limit" {
			var limit int
			switch n := value.(type) {
			case int:
				limit = n
			case int64:
				limit = int(n)
				if int64(limit) != n {
					limit = 0
				}
			case float64:
				if n >= 1 && n <= 200 && n == float64(int(n)) {
					limit = int(n)
				}
			default:
				return "", &APIError{Code: "invalid_filter", Message: "limit must be an integer from 1 to 200"}
			}
			if limit < 1 || limit > 200 {
				return "", &APIError{Code: "invalid_filter", Message: "limit must be an integer from 1 to 200"}
			}
			query.Set(name, fmt.Sprint(limit))
		} else {
			v, ok := value.(string)
			if !ok {
				return "", &APIError{Code: "invalid_filter", Message: "Filter values other than limit must be strings"}
			}
			query.Set(name, v)
		}
	}
	return query.Encode(), nil
}

func (c *Client) list(ctx context.Context, path string, query map[string]any, allowed string) (map[string]any, error) {
	encoded, err := filters(query, allowed)
	if err != nil {
		return nil, err
	}
	return c.request(ctx, "GET", path, encoded, nil, "", false)
}

func (c *Client) object(ctx context.Context, method, path string, body map[string]any, key string) (map[string]any, error) {
	if body == nil {
		return nil, c.failure(0, "invalid_body", "Request body must be a JSON object", "")
	}
	return c.request(ctx, method, path, "", body, key, false)
}

func (c *Client) resource(ctx context.Context, method, prefix, id, suffix string) (map[string]any, error) {
	escaped, err := resourceID(id)
	if err != nil {
		return nil, err
	}
	return c.request(ctx, method, prefix+escaped+suffix, "", nil, "", false)
}

func (c *Client) CreateWatch(ctx context.Context, body map[string]any) (map[string]any, error) {
	return c.object(ctx, "POST", "/v1/watches", body, "")
}
func (c *Client) ListWatches(ctx context.Context, query map[string]any) (map[string]any, error) {
	return c.list(ctx, "/v1/watches", query, "project chain limit before")
}
func (c *Client) GetWatch(ctx context.Context, id string) (map[string]any, error) {
	return c.resource(ctx, "GET", "/v1/watches/", id, "")
}
func (c *Client) DeleteWatch(ctx context.Context, id string) (map[string]any, error) {
	return c.resource(ctx, "DELETE", "/v1/watches/", id, "")
}
func (c *Client) ListEvents(ctx context.Context, query map[string]any) (map[string]any, error) {
	return c.list(ctx, "/v1/events", query, "project watch_id chain type address tx_id limit before")
}
func (c *Client) ListPayments(ctx context.Context, query map[string]any) (map[string]any, error) {
	return c.list(ctx, "/v1/payments", query, "project watch_id chain type address tx_id limit before")
}
func (c *Client) CreateInvoice(ctx context.Context, body map[string]any, idempotencyKey string) (map[string]any, error) {
	if !regexp.MustCompile(`^[A-Za-z0-9_-]{16,80}$`).MatchString(idempotencyKey) {
		return nil, c.failure(0, "invalid_idempotency_key", "Idempotency key must contain 16–80 ASCII letters, digits, dashes or underscores", "")
	}
	return c.object(ctx, "POST", "/v1/invoices", body, idempotencyKey)
}
func (c *Client) ListInvoices(ctx context.Context, query map[string]any) (map[string]any, error) {
	return c.list(ctx, "/v1/invoices", query, "project chain status limit before")
}
func (c *Client) GetInvoice(ctx context.Context, id string) (map[string]any, error) {
	return c.resource(ctx, "GET", "/v1/invoices/", id, "")
}
func (c *Client) CancelInvoice(ctx context.Context, id string) (map[string]any, error) {
	return c.resource(ctx, "POST", "/v1/invoices/", id, "/cancel")
}
func (c *Client) CreateEndpoint(ctx context.Context, body map[string]any) (map[string]any, error) {
	return c.object(ctx, "POST", "/v1/endpoints", body, "")
}
func (c *Client) ListEndpoints(ctx context.Context) (map[string]any, error) {
	return c.request(ctx, "GET", "/v1/endpoints", "", nil, "", false)
}
func (c *Client) DeleteEndpoint(ctx context.Context, id string) (map[string]any, error) {
	return c.resource(ctx, "DELETE", "/v1/endpoints/", id, "")
}
func (c *Client) GetHealth(ctx context.Context) (map[string]any, error) {
	return c.request(ctx, "GET", "/v1/health", "", nil, "", true)
}
func (c *Client) ListChains(ctx context.Context) (map[string]any, error) {
	return c.request(ctx, "GET", "/v1/chains", "", nil, "", true)
}

// StreamEvents возвращает сырые SSE bytes. Вызывающий обязан закрыть Body.
// Это best-effort поток без replay, автоматических reconnect и JSON buffering.
func (c *Client) StreamEvents(ctx context.Context) (io.ReadCloser, error) {
	resp, err := c.send(ctx, "GET", "/v1/stream", "", nil, "", false, true)
	if err != nil {
		return nil, err
	}
	if !strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
		resp.Body.Close()
		return nil, c.failure(resp.StatusCode, "invalid_response", "Expected text/event-stream", "")
	}
	return resp.Body, nil
}
