// Package wantupay 是顽兔支付的 Go 商户 SDK（beta）。
//
// 复制本文件进你的项目即可使用，只依赖标准库（Go 1.21+）；包名可按你的项目习惯改。
// 商户侧只有三件事：调接口、验 webhook、幂等入账。
//
// 命名口径：线上报文一律 snake_case，本 SDK 的字段用 Go 惯例的导出名（首字母大写是语言硬性
// 要求），报文名写在 json 标签里，两者一一对应；方法名与 Python/Node 版一一对应。
package wantupay

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// Version 是本 SDK 的版本。beta：接口可能调整，生产接入前请与顽兔确认。
const Version = "0.2.0-beta"

// 默认值：与 Python / Node 版一致。
const (
	defaultTimeout      = 10 * time.Second
	defaultToleranceSec = 300
	// 出错时保留的响应正文长度（字节），排障够用又不刷屏。
	bodyPreviewLen = 300
	// nonce 随机字节数，hex 后 24 位，落在网关认可的 8~64 位区间。
	nonceBytes = 12
)

var (
	outNoRe  = regexp.MustCompile(`^[A-Za-z0-9_]{1,64}$`)
	amountRe = regexp.MustCompile(`^(0|[1-9]\d*)(\.\d{1,2})?$`)
	digitsRe = regexp.MustCompile(`^\d+$`)
)

// 订单状态。
const (
	StatusCreated         = "created"
	StatusPaying          = "paying"
	StatusPaid            = "paid"
	StatusPartialRefunded = "partial_refunded"
	StatusRefunded        = "refunded"
	StatusClosed          = "closed"
)

// 退款状态。
const (
	RefundProcessing = "processing"
	RefundSucceeded  = "succeeded"
	RefundFailed     = "failed"
)

// 回调事件类型。
const (
	EventOrderPaid       = "order.paid"
	EventRefundSucceeded = "refund.succeeded"
	EventRefundFailed    = "refund.failed"
	EventWebhookPing     = "webhook.ping"
)

// ---------- 错误 ----------

// ConfigError 是本地入参或配置错误，请求未发出。
type ConfigError struct{ Message string }

func (e *ConfigError) Error() string { return e.Message }

// HTTPError 是网络失败或响应异常。查询类接口可重试。
type HTTPError struct {
	Message string
	// Status 是 HTTP 状态码；请求没发出去（网络错误）时为 0。
	Status int
	// Body 是响应正文开头一截，排障用。
	Body string
	Err  error
}

func (e *HTTPError) Error() string { return e.Message }
func (e *HTTPError) Unwrap() error { return e.Err }

// APIError 是网关返回的业务失败（code != 0），错误码见接口文档第 8 节。
type APIError struct {
	Code       int
	Message    string
	HTTPStatus int
	// 渠道原因透传（仅排障展示用，业务逻辑不要依赖）。
	ChannelCode    string
	ChannelMessage string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("顽兔支付网关业务失败 %d: %s", e.Code, e.Message)
}

// WebhookVerifyError 表示回调不可信：不要处理业务，返回非 2xx 让网关重试。
type WebhookVerifyError struct{ Message string }

func (e *WebhookVerifyError) Error() string { return e.Message }

// ---------- 客户端 ----------

// Config 是创建客户端的参数。
type Config struct {
	// BaseURL 网关地址，如 https://pay-api.wantutech.com（尾斜杠可有可无）。
	BaseURL string
	// MerchantID 商户号，如 mch_7f2a9c。
	MerchantID string
	// APISecret 接口签名密钥。只从环境变量/密钥管理注入，绝不进代码库。
	APISecret string
	// WebhookSecret 可选独立回调密钥；未配置则回调验签共用 APISecret。
	WebhookSecret string
	// Timeout 单次请求超时，默认 10 秒。HTTPClient 自带超时时以后者为准。
	Timeout time.Duration
	// HTTPClient 自定义 HTTP 客户端（测试注入或走代理时用），缺省按 Timeout 新建。
	HTTPClient *http.Client
}

// Client 是顽兔支付客户端，并发安全，建一个复用即可。
type Client struct {
	baseURL       string
	merchantID    string
	apiSecret     string
	webhookSecret string
	httpClient    *http.Client
}

// NewClient 创建客户端；BaseURL / MerchantID / APISecret 缺一不可。
func NewClient(cfg Config) (*Client, error) {
	if strings.TrimSpace(cfg.BaseURL) == "" {
		return nil, &ConfigError{Message: "BaseURL 不能为空"}
	}
	if strings.TrimSpace(cfg.MerchantID) == "" || strings.TrimSpace(cfg.APISecret) == "" {
		return nil, &ConfigError{Message: "MerchantID 与 APISecret 不能为空"}
	}
	httpClient := cfg.HTTPClient
	if httpClient == nil {
		timeout := cfg.Timeout
		if timeout <= 0 {
			timeout = defaultTimeout
		}
		httpClient = &http.Client{Timeout: timeout}
	}
	return &Client{
		baseURL:       strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"),
		merchantID:    strings.TrimSpace(cfg.MerchantID),
		apiSecret:     cfg.APISecret,
		webhookSecret: cfg.WebhookSecret,
		httpClient:    httpClient,
	}, nil
}

// ---------- 请求与返回 ----------

// CreateOrderParams 是下单入参，字段与报文一一对应（见 json 标签）。
type CreateOrderParams struct {
	// 商户订单号：1-64 位字母/数字/下划线，商户侧唯一，也是入账幂等键。
	OutTradeNo string `json:"out_trade_no"`
	// 金额（元）字符串，最多两位小数。务必以服务端价格为准，绝不信任前端传值。
	Amount string `json:"amount"`
	// 订单标题，不可含 / = & 特殊字符。
	Subject string `json:"subject"`
	// qrcode=二维码收款；redirect=整页跳转。
	PayMode string `json:"pay_mode"`
	// 支付渠道：留空或 alipay=支付宝；chinaums=银联商务聚合码。
	Channel string `json:"channel,omitempty"`
	// qrcode 模式下二维码宽度（px），默认 200，仅支付宝生效。
	QRCodeWidth int `json:"qrcode_width,omitempty"`
	// redirect 模式支付完成后的回跳地址；仅作展示回跳，入账只认回调/查单。
	ReturnURL string `json:"return_url,omitempty"`
	// 这张单的回调地址，http/https，最长 512 字符，三种 webhook 事件都发到它。只有商户后台「接入配置」把回调方式
	// 切成「按单指定」才能传，而且必传，值要和白名单里登记的地址一字不差；默认的「统一地址」模式下传了报 42202。
	NotifyURL string `json:"notify_url,omitempty"`
	// 未支付自动关单时限（分钟），默认 15，范围 2 到 1440。
	ExpireMinutes int `json:"expire_minutes,omitempty"`
	// ≤128 字符，回调原样带回（如内部用户 ID）。
	Attach string `json:"attach,omitempty"`
}

// OrderRef 定位一张订单：OutTradeNo 与 OrderID 传一个即可，两个都传以 OutTradeNo 为准。
type OrderRef struct {
	OutTradeNo string `json:"out_trade_no,omitempty"`
	OrderID    string `json:"order_id,omitempty"`
}

// CreateRefundParams 是申请退款入参。
type CreateRefundParams struct {
	OutTradeNo string `json:"out_trade_no"`
	// 商户退款单号：幂等键，重试请用同一单号。
	OutRefundNo string `json:"out_refund_no"`
	Amount      string `json:"amount"`
	// 退款原因，≤256 字符，透传给支付渠道。
	Reason string `json:"reason,omitempty"`
}

// QueryRefundParams 是退款查询入参，两项都必填。
type QueryRefundParams struct {
	OutTradeNo  string `json:"out_trade_no"`
	OutRefundNo string `json:"out_refund_no"`
}

// CreateOrderResult 是下单应答。
type CreateOrderResult struct {
	OrderID    string `json:"order_id"`
	OutTradeNo string `json:"out_trade_no"`
	// 下单时选择的渠道，决定 PayURL 的用法。
	Channel  string `json:"channel"`
	PayURL   string `json:"pay_url"`
	ExpireAt string `json:"expire_at"`
}

// OrderResult 是查单与关单的应答。
type OrderResult struct {
	OrderID    string `json:"order_id"`
	OutTradeNo string `json:"out_trade_no"`
	Status     string `json:"status"`
	Amount     string `json:"amount"`
	// 支付时间，只有支付后才有。
	PaidAt  string `json:"paid_at"`
	Channel string `json:"channel"`
	// 渠道侧流水号，只有支付后才有，仅对账展示用。
	ChannelTradeNo string `json:"channel_trade_no"`
}

// RefundResult 是申请退款与退款查询的应答。
type RefundResult struct {
	RefundID    string `json:"refund_id"`
	OutTradeNo  string `json:"out_trade_no"`
	OutRefundNo string `json:"out_refund_no"`
	Amount      string `json:"amount"`
	// 受理与审核中一律 processing；终态 succeeded / failed。
	Status string `json:"status"`
	// 退款到账时间，只有 succeeded 才有。
	RefundedAt string `json:"refunded_at"`
	// 仅 failed 时返回：rejected / expired / channel。
	FailReason string `json:"fail_reason"`
}

// ---------- 五个接口 ----------

// CreateOrder 创建支付单。同单号同参数幂等。
func (c *Client) CreateOrder(ctx context.Context, params CreateOrderParams) (*CreateOrderResult, error) {
	if !outNoRe.MatchString(params.OutTradeNo) {
		return nil, &ConfigError{Message: "out_trade_no 需为 1-64 位字母/数字/下划线"}
	}
	if !amountRe.MatchString(params.Amount) {
		return nil, &ConfigError{Message: "amount 必须是字符串金额（元，最多两位小数），如 \"100.00\""}
	}
	if params.PayMode != "qrcode" && params.PayMode != "redirect" {
		return nil, &ConfigError{Message: "pay_mode 只能是 \"qrcode\" 或 \"redirect\""}
	}
	if params.Channel != "" && params.Channel != "alipay" && params.Channel != "chinaums" {
		return nil, &ConfigError{Message: "channel 只能是 \"alipay\"（支付宝）或 \"chinaums\"（银商）"}
	}
	if params.ReturnURL != "" && params.PayMode != "redirect" {
		return nil, &ConfigError{Message: "return_url 仅在 pay_mode=\"redirect\" 下有效（qrcode 场景请用轮询）"}
	}
	var out CreateOrderResult
	if err := c.call(ctx, "/v1/orders", params, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

// QueryOrder 查询支付单。回调之外的兜底手段。
func (c *Client) QueryOrder(ctx context.Context, ref OrderRef) (*OrderResult, error) {
	if err := ref.validate(); err != nil {
		return nil, err
	}
	var out OrderResult
	if err := c.call(ctx, "/v1/orders/query", ref, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

// CloseOrder 关闭未支付的单。
//
// 用户取消、改金额换单时调用：网关同步到渠道关单，旧二维码/支付链接随即失效，不用等超时。
// 已关闭的单幂等返回；已支付的单返回 *APIError（Code=40902），按已支付处理（入账或退款）。
func (c *Client) CloseOrder(ctx context.Context, ref OrderRef) (*OrderResult, error) {
	if err := ref.validate(); err != nil {
		return nil, err
	}
	var out OrderResult
	if err := c.call(ctx, "/v1/orders/close", ref, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

// CreateRefund 申请退款。OutRefundNo 是幂等键，网络失败后用同一单号重试是安全的。
func (c *Client) CreateRefund(ctx context.Context, params CreateRefundParams) (*RefundResult, error) {
	if !outNoRe.MatchString(params.OutRefundNo) {
		return nil, &ConfigError{Message: "out_refund_no 需为 1-64 位字母/数字/下划线"}
	}
	if !amountRe.MatchString(params.Amount) {
		return nil, &ConfigError{Message: "amount 必须是字符串金额（元，最多两位小数）"}
	}
	var out RefundResult
	if err := c.call(ctx, "/v1/refunds", params, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

// QueryRefund 查询退款。Status 为 processing / succeeded / failed；failed 时 FailReason
// 区分 rejected（商户后台拒绝）/ expired（超 14 天未审批自动拒绝）/ channel（渠道失败）。
func (c *Client) QueryRefund(ctx context.Context, params QueryRefundParams) (*RefundResult, error) {
	if params.OutTradeNo == "" || params.OutRefundNo == "" {
		return nil, &ConfigError{Message: "out_trade_no 与 out_refund_no 都必须传"}
	}
	var out RefundResult
	if err := c.call(ctx, "/v1/refunds/query", params, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

func (r OrderRef) validate() error {
	if r.OutTradeNo == "" && r.OrderID == "" {
		return &ConfigError{Message: "out_trade_no 与 order_id 至少传一个"}
	}
	return nil
}

// ---------- webhook 验签 ----------

// WebhookData 是回调事件的业务数据，四类事件共用一个结构，取用其中有值的字段即可。
type WebhookData struct {
	OrderID        string `json:"order_id"`
	OutTradeNo     string `json:"out_trade_no"`
	Amount         string `json:"amount"`
	PaidAt         string `json:"paid_at"`
	Channel        string `json:"channel"`
	ChannelTradeNo string `json:"channel_trade_no"`
	Attach         string `json:"attach"`
	RefundID       string `json:"refund_id"`
	OutRefundNo    string `json:"out_refund_no"`
	RefundedAt     string `json:"refunded_at"`
	// 仅 refund.failed：rejected / expired / channel。
	FailReason string `json:"fail_reason"`
	// 仅 webhook.ping（商户后台发的测试回调）。
	MerchantID string `json:"merchant_id"`
}

// WebhookEvent 是验签通过后的回调事件。按 EventType 分支处理；
// 不认识的类型直接返回 2xx，不要报错（网关以后新增事件也会原样送达）。
type WebhookEvent struct {
	EventID    string      `json:"event_id"`
	EventType  string      `json:"event_type"`
	OccurredAt string      `json:"occurred_at"`
	Data       WebhookData `json:"data"`
	// Raw 是报文原文，需要读结构体里没有的新字段时用。
	Raw json.RawMessage `json:"-"`
}

// VerifyWebhook 验签并解析回调事件，时间戳容差取默认的 300 秒。
//
// rawBody 必须是请求体原文（先 io.ReadAll(r.Body) 拿到字节，任何 JSON 解析放在验签之后）。
// 验签失败返回 *WebhookVerifyError，此时不要处理业务，回非 2xx 状态即可，网关会按
// 1m→12h 的节奏自动重试。
//
// 验签通过后仍需：核对 Data.Amount 与本地订单一致 + 以 out_trade_no（或 EventID）幂等入账。
func (c *Client) VerifyWebhook(header http.Header, rawBody []byte) (*WebhookEvent, error) {
	return c.VerifyWebhookWithTolerance(header, rawBody, defaultToleranceSec)
}

// VerifyWebhookWithTolerance 同 VerifyWebhook，另指定时间戳容差（秒）；传 0 用默认值。
// 只有商户服务器时钟偏差确实压不下来时才需要放宽。
func (c *Client) VerifyWebhookWithTolerance(header http.Header, rawBody []byte, toleranceSec int) (*WebhookEvent, error) {
	if toleranceSec <= 0 {
		toleranceSec = defaultToleranceSec
	}
	timestamp := header.Get("X-Wantu-Timestamp")
	nonce := header.Get("X-Wantu-Nonce")
	signature := header.Get("X-Wantu-Signature")
	if timestamp == "" || nonce == "" || signature == "" {
		return nil, &WebhookVerifyError{Message: "缺少 X-Wantu-Timestamp / Nonce / Signature 请求头"}
	}
	if !digitsRe.MatchString(timestamp) {
		return nil, &WebhookVerifyError{Message: "时间戳格式不正确"}
	}
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return nil, &WebhookVerifyError{Message: "时间戳格式不正确"}
	}
	if diff := time.Now().Unix() - ts; diff > int64(toleranceSec) || diff < -int64(toleranceSec) {
		return nil, &WebhookVerifyError{Message: "webhook 时间戳超窗"}
	}
	// 配置了独立 WebhookSecret 用它，否则与请求签名共用 APISecret
	secret := c.webhookSecret
	if secret == "" {
		secret = c.apiSecret
	}
	expected := sign(secret, timestamp, nonce, string(rawBody))
	if !hmac.Equal([]byte(expected), []byte(signature)) {
		return nil, &WebhookVerifyError{Message: "webhook 验签失败：报文不可信"}
	}

	trimmed := bytes.TrimSpace(rawBody)
	if len(trimmed) == 0 || trimmed[0] != '{' {
		return nil, &WebhookVerifyError{Message: "webhook 请求体不是 JSON 对象"}
	}
	var event WebhookEvent
	if err := json.Unmarshal(trimmed, &event); err != nil {
		return nil, &WebhookVerifyError{Message: "webhook 请求体不是合法 JSON"}
	}
	// 复制一份：Raw 要能比 rawBody 活得久，不与调用方的切片共享底层数组
	event.Raw = append(json.RawMessage(nil), trimmed...)
	return &event, nil
}

// ---------- 内部 ----------

// Sign 按网关口径生成签名：三行拼接 timestamp\nnonce\n请求体原文，HMAC-SHA256 后 hex 小写。
// 自行实现签名时可拿它对拍。
func sign(secret, timestamp, nonce, body string) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "\n" + nonce + "\n" + body))
	return hex.EncodeToString(mac.Sum(nil))
}

// marshalBody 生成要签名也要发送的报文原文：关掉 HTML 转义，与其他语言版本逐字节一致。
func marshalBody(payload any) ([]byte, error) {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(payload); err != nil {
		return nil, err
	}
	// Encode 会补一个换行，签名与发送都不要它
	return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

func (c *Client) call(ctx context.Context, path string, payload any, out any) error {
	body, err := marshalBody(payload)
	if err != nil {
		return &ConfigError{Message: "请求参数无法序列化为 JSON: " + err.Error()}
	}
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	nonce, err := newNonce()
	if err != nil {
		return &HTTPError{Message: "生成随机串失败", Err: err}
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
	if err != nil {
		return &HTTPError{Message: "构造请求失败: " + path, Err: err}
	}
	req.Header.Set("Content-Type", "application/json; charset=utf-8")
	req.Header.Set("X-Wantu-Merchant-Id", c.merchantID)
	req.Header.Set("X-Wantu-Timestamp", timestamp)
	req.Header.Set("X-Wantu-Nonce", nonce)
	req.Header.Set("X-Wantu-Signature", sign(c.apiSecret, timestamp, nonce, string(body)))

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return &HTTPError{Message: "请求顽兔支付网关失败: " + path, Err: err}
	}
	defer resp.Body.Close()
	text, err := io.ReadAll(resp.Body)
	if err != nil {
		return &HTTPError{Message: "读取网关响应失败: " + path, Status: resp.StatusCode, Err: err}
	}

	var envelope struct {
		Code           *int            `json:"code"`
		Message        string          `json:"message"`
		Data           json.RawMessage `json:"data"`
		ChannelCode    string          `json:"channel_code"`
		ChannelMessage string          `json:"channel_message"`
	}
	if err := json.Unmarshal(text, &envelope); err != nil {
		return &HTTPError{
			Message: fmt.Sprintf("网关响应不是 JSON（HTTP %d）", resp.StatusCode),
			Status:  resp.StatusCode,
			Body:    preview(text),
		}
	}
	if envelope.Code == nil {
		return &HTTPError{Message: "网关响应缺少 code 字段", Status: resp.StatusCode, Body: preview(text)}
	}
	if *envelope.Code != 0 {
		message := envelope.Message
		if message == "" {
			message = "未知错误"
		}
		return &APIError{
			Code:           *envelope.Code,
			Message:        message,
			HTTPStatus:     resp.StatusCode,
			ChannelCode:    envelope.ChannelCode,
			ChannelMessage: envelope.ChannelMessage,
		}
	}
	if len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) {
		return nil
	}
	if err := json.Unmarshal(envelope.Data, out); err != nil {
		return &HTTPError{Message: "网关响应的 data 结构不符合预期", Status: resp.StatusCode, Body: preview(text)}
	}
	return nil
}

func newNonce() (string, error) {
	buf := make([]byte, nonceBytes)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	return hex.EncodeToString(buf), nil
}

func preview(text []byte) string {
	if len(text) > bodyPreviewLen {
		return string(text[:bodyPreviewLen])
	}
	return string(text)
}
