// Package zrpc is a minimal JSON-RPC client for talking to dragonxd.
//
// It replaces github.com/btcsuite/btcd/rpcclient, of which lightwalletd used
// exactly one method: RawRequest. That package is unusable here for two
// reasons, both of which this package exists to fix:
//
//  1. NO TIMEOUT, AND NO WAY TO SET ONE. rpcclient builds its http.Client in an
//     unexported newHTTPClient() and its ConnConfig exposes no Timeout field, so
//     a request can hang forever. Calls were observed hanging for over five
//     minutes against a healthy node that answered the same query from the CLI
//     in 2ms.
//
//  2. EVERY CALL IN THE PROCESS IS SERIALISED. In HTTP POST mode rpcclient runs
//     a single sendPostHandler goroutine which invokes handleSendPostMessage
//     SYNCHRONOUSLY, so at most one RPC is ever in flight. Combined with (1),
//     one stuck call blocks the block ingestor, the mempool monitor and every
//     user-facing gRPC handler indefinitely. A timeout alone would not fix this:
//     bounding the caller's wait still leaves the shared goroutine stuck on
//     http.Client.Do, so everything queued behind it stays blocked. Only a
//     timeout on the HTTP client itself aborts the in-flight request, and only
//     dropping the shared goroutine lets independent callers proceed.
//
// The wire format, request envelope, ID sequence and error semantics are
// deliberately byte-identical to rpcclient's RawRequest. In particular
// RPCError.Error() must render as "<code>: <message>", because callers parse
// the code back out of the string (see common.GetSaplingInfo, which checks for
// code -8 via strings.SplitN(err.Error(), ":", 2)).
package zrpc

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"net/http"
	"sync/atomic"
	"time"
)

// RPCError is a JSON-RPC error object returned by dragonxd.
type RPCError struct {
	Code    int64  `json:"code,omitempty"`
	Message string `json:"message,omitempty"`
}

// Error renders as "<code>: <message>". Callers depend on this exact shape to
// recover the numeric code; do not change it.
func (e *RPCError) Error() string {
	return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

type request struct {
	Jsonrpc string            `json:"jsonrpc"`
	Method  string            `json:"method"`
	Params  []json.RawMessage `json:"params"`
	ID      int64             `json:"id"`
}

type rawResponse struct {
	Result json.RawMessage `json:"result"`
	Error  *RPCError       `json:"error"`
}

// Client is safe for concurrent use by multiple goroutines.
type Client struct {
	url    string
	user   string
	pass   string
	http   *http.Client
	nextID int64
}

// New returns a client for a dragonxd JSON-RPC endpoint.
//
// timeout of 0 means no timeout, which reproduces the old unbounded behaviour
// and should not be used in production.
//
// maxConcurrent bounds how many requests may be in flight at once. This is not
// optional book-keeping: rpcclient's single goroutine imposed an accidental
// ceiling of ONE, and removing it without putting anything in its place would
// let a burst of gRPC handlers fan out arbitrarily wide. grpc-go places no
// limit of its own here -- this server sets no MaxConcurrentStreams, so the
// default is math.MaxUint32. dragonxd serves RPC with 8 worker threads
// (DEFAULT_HTTP_THREADS) behind a 4096-deep work queue, and on the pool node
// those threads are shared with getblocktemplate, so overload shows up as
// queueing latency for mining rather than as an error we could back off on.
// A small number still removes all of the head-of-line blocking.
func New(addr, user, pass string, timeout time.Duration, maxConcurrent int) *Client {
	if maxConcurrent < 1 {
		maxConcurrent = 1
	}
	return &Client{
		url:  "http://" + addr,
		user: user,
		pass: pass,
		http: &http.Client{
			// Covers the whole exchange: connect, write, response headers and
			// body read. This is the bound that was missing.
			Timeout: timeout,
			Transport: &http.Transport{
				// dragonxd's HTTP server supports keep-alive. rpcclient set
				// Close=true and opened a fresh TCP connection per request,
				// which left hundreds of sockets in TIME_WAIT on a busy node.
				//
				// MaxConnsPerHost is the real concurrency bound: it BLOCKS a
				// caller once the limit is reached rather than dialling more,
				// which is the backpressure we want. MaxIdleConnsPerHost only
				// caps reuse, so on its own it would let us exceed the limit
				// and go back to churning connections.
				MaxConnsPerHost:     maxConcurrent,
				MaxIdleConns:        maxConcurrent,
				MaxIdleConnsPerHost: maxConcurrent,
				// Must stay BELOW dragonxd's own idle timeout, which is 30s
				// (DEFAULT_HTTP_SERVER_TIMEOUT in httpserver.h, applied via
				// evhttp_set_timeout and not overridden in DRAGONX.conf).
				// Whoever closes second loses a race against a FIN already in
				// flight, and Go will not retry a POST once bytes are on the
				// wire -- so we close first.
				IdleConnTimeout: 20 * time.Second,
			},
		},
	}
}

// RawRequest sends a JSON-RPC request and returns the raw result. A JSON-RPC
// error from the server is returned as *RPCError.
func (c *Client) RawRequest(method string, params []json.RawMessage) (json.RawMessage, error) {
	if method == "" {
		return nil, errors.New("no method")
	}
	// Marshal parameters as "[]" instead of "null" when none are passed.
	if params == nil {
		params = []json.RawMessage{}
	}

	body, err := json.Marshal(&request{
		Jsonrpc: "1.0",
		Method:  method,
		Params:  params,
		ID:      atomic.AddInt64(&c.nextID, 1),
	})
	if err != nil {
		return nil, err
	}

	httpReq, err := http.NewRequest("POST", c.url, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.SetBasicAuth(c.user, c.pass)

	httpResp, err := c.http.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer httpResp.Body.Close()

	respBytes, err := ioutil.ReadAll(httpResp.Body)
	if err != nil {
		return nil, fmt.Errorf("error reading json reply: %v", err)
	}

	var resp rawResponse
	if err := json.Unmarshal(respBytes, &resp); err != nil {
		// Not a valid JSON-RPC response: surface the status and raw body, the
		// same way rpcclient did. dragonxd returns non-JSON bodies for some
		// auth and workqueue failures, and callers log this verbatim.
		return nil, fmt.Errorf("status code: %d, response: %q",
			httpResp.StatusCode, string(respBytes))
	}
	if resp.Error != nil {
		return nil, resp.Error
	}
	return resp.Result, nil
}
