package wecombot

import (
	"context"
	"encoding/json"
	"net/url"
	"strconv"
	"strings"

	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
)

// Handlers returns the Action dispatch table. The keys must exactly match the
// Definition catalog; bootstrap verifies the coverage.
func Handlers() map[string]runner.RESTHandler {
	return map[string]runner.RESTHandler{
		ActionSendTextMessage:       handleSendTextMessage,
		ActionSendMarkdownMessage:   handleSendMarkdownMessage,
		ActionSendMarkdownV2Message: handleSendMarkdownV2Message,
		ActionSendImageMessage:      handleSendImageMessage,
		ActionSendNewsMessage:       handleSendNewsMessage,
	}
}

const (
	webhookPath         = "/cgi-bin/webhook/send"
	textMaxBytes        = 2048
	markdownMaxBytes    = 4096
	requestFailedPrefix = "wecom bot request failed"
)

// callPhase selects the error classification contract: validate blames the
// credential for recognized business rejections, execute keeps the upstream
// business-code semantics.
type callPhase string

const (
	phaseValidate callPhase = "validate"
	phaseExecute  callPhase = "execute"
)

// flexInt decodes the tolerant WeCom integer: a JSON number or a numeric
// string, anything else decoding to nil.
type flexInt struct {
	Value   int64
	Present bool
}

func (value *flexInt) UnmarshalJSON(raw []byte) error {
	trimmed := strings.TrimSpace(string(raw))
	if trimmed == "null" {
		return nil
	}
	digits := strings.Trim(trimmed, `"`)
	if digits == "" {
		return nil
	}
	parsed, err := strconv.ParseInt(digits, 10, 64)
	if err != nil {
		return nil
	}
	value.Value, value.Present = parsed, true
	return nil
}

// wecomEnvelope is the normalized WeCom webhook response. The error pair is
// read from the lowercase, capitalized, and error_msg spellings the API mixes.
type wecomEnvelope struct {
	ErrCode       flexInt `json:"errcode"`
	ErrCodeLegacy flexInt `json:"ErrCode"`
	ErrMsg        string  `json:"errmsg"`
	ErrMsgLegacy  string  `json:"ErrMsg"`
	ErrMsgAlt     string  `json:"error_msg"`
}

func (envelope *wecomEnvelope) code() (int64, bool) {
	if envelope.ErrCode.Present {
		return envelope.ErrCode.Value, true
	}
	if envelope.ErrCodeLegacy.Present {
		return envelope.ErrCodeLegacy.Value, true
	}
	return 0, false
}

func (envelope *wecomEnvelope) message() string {
	if envelope.ErrMsg != "" {
		return envelope.ErrMsg
	}
	if envelope.ErrMsgLegacy != "" {
		return envelope.ErrMsgLegacy
	}
	return envelope.ErrMsgAlt
}

func decodeInput(input json.RawMessage, target any) error {
	if len(input) == 0 {
		return nil
	}
	if err := json.Unmarshal(input, target); err != nil {
		return runner.Errorf(
			runner.ErrorValidation, "action input was not decodable", err)
	}
	return nil
}

// requireUtf8Content enforces the WeCom per-message-type byte caps on the
// UTF-8 encoding, not the rune count.
func requireUtf8Content(value, field string, maxBytes int) (string, error) {
	if value == "" {
		return "", runner.Errorf(
			runner.ErrorValidation, field+" is required", nil)
	}
	if len(value) > maxBytes {
		return "", runner.Errorf(
			runner.ErrorValidation,
			field+" must be at most "+strconv.Itoa(maxBytes)+" UTF-8 bytes", nil)
	}
	return value, nil
}

func handleSendTextMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		Content             string    `json:"content"`
		MentionedList       *[]string `json:"mentionedList"`
		MentionedMobileList *[]string `json:"mentionedMobileList"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	content, err := requireUtf8Content(parsed.Content, "content", textMaxBytes)
	if err != nil {
		return nil, err
	}
	text := map[string]any{"content": content}
	if parsed.MentionedList != nil {
		text["mentioned_list"] = *parsed.MentionedList
	}
	if parsed.MentionedMobileList != nil {
		text["mentioned_mobile_list"] = *parsed.MentionedMobileList
	}
	return sendWebhookMessage(ctx, client, credentials, map[string]any{
		"msgtype": "text",
		"text":    text,
	})
}

func handleSendMarkdownMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	return sendMarkdownVariant(ctx, client, credentials, input, "markdown")
}

func handleSendMarkdownV2Message(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	return sendMarkdownVariant(ctx, client, credentials, input, "markdown_v2")
}

func sendMarkdownVariant(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
	variant string,
) (json.RawMessage, error) {
	var parsed struct {
		Content string `json:"content"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	content, err := requireUtf8Content(parsed.Content, "content", markdownMaxBytes)
	if err != nil {
		return nil, err
	}
	return sendWebhookMessage(ctx, client, credentials, map[string]any{
		"msgtype": variant,
		variant:   map[string]any{"content": content},
	})
}

func handleSendImageMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		Base64 string `json:"base64"`
		MD5    string `json:"md5"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	return sendWebhookMessage(ctx, client, credentials, map[string]any{
		"msgtype": "image",
		"image": map[string]any{
			"base64": parsed.Base64,
			"md5":    parsed.MD5,
		},
	})
}

func handleSendNewsMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		Articles []newsArticleInput `json:"articles"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	if len(parsed.Articles) == 0 {
		return nil, runner.Errorf(
			runner.ErrorValidation, "articles is required", nil)
	}
	articles := make([]map[string]any, 0, len(parsed.Articles))
	for _, article := range parsed.Articles {
		articles = append(articles, article.payload())
	}
	return sendWebhookMessage(ctx, client, credentials, map[string]any{
		"msgtype": "news",
		"news":    map[string]any{"articles": articles},
	})
}

// newsArticleInput keeps the article passthrough shape: title/url always,
// description/picurl only when the input carried them.
type newsArticleInput struct {
	Title       *string `json:"title"`
	Description *string `json:"description"`
	URL         *string `json:"url"`
	PicURL      *string `json:"picurl"`
}

func (article *newsArticleInput) payload() map[string]any {
	payload := map[string]any{}
	if article.Title != nil {
		payload["title"] = *article.Title
	}
	if article.Description != nil {
		payload["description"] = *article.Description
	}
	if article.URL != nil {
		payload["url"] = *article.URL
	}
	if article.PicURL != nil {
		payload["picurl"] = *article.PicURL
	}
	return payload
}

// sendWebhookMessage executes one webhook POST and enforces the upstream
// success contract (errcode == 0).
func sendWebhookMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	payload map[string]any,
) (json.RawMessage, error) {
	envelope, raw, err := callWebhook(ctx, client, credentials, payload)
	if err != nil {
		return nil, err
	}
	if envelope == nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "provider response was not decodable", nil)
	}
	code, present := envelope.code()
	if !present || code != 0 {
		return nil, classifyEnvelope(envelope, raw, phaseExecute)
	}
	return json.Marshal(map[string]any{
		"errcode": code,
		"errmsg":  nonEmpty(envelope.message(), "ok"),
	})
}

// callWebhook performs the shared webhook round trip: resolve the key, bind
// it as the query parameter, and classify failures for the validation probe
// path.
func callWebhook(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	payload map[string]any,
) (*wecomEnvelope, json.RawMessage, error) {
	key, err := resolveWebhookKey(credentials[APIKeyField])
	if err != nil {
		if isValidateProbe(payload) {
			return nil, nil, err
		}
		// Outside the probe the credential comes from the stored
		// connection: an unresolvable stored value is a connection
		// problem, not an action-input validation failure.
		return nil, nil, runner.Errorf(
			runner.ErrorConnectionUnavailable,
			"stored provider credential is unusable", err)
	}
	query := url.Values{}
	query.Set("key", key)
	raw, callErr := client.Call(ctx, credentials, runner.RESTRequest{
		Method: "POST",
		Path:   webhookPath,
		Query:  query,
		Body:   payload,
	})
	if callErr != nil {
		code := runner.CodeOf(callErr)
		// Rate limits and transport/decode failures keep their own class in
		// every phase; a rejected request during the validation probe blames
		// the credential, exactly like the upstream validate mapping.
		if code == runner.ErrorRateLimited || code == runner.ErrorUpstreamFailure {
			return nil, nil, callErr
		}
		if isValidateProbe(payload) {
			return nil, nil, runner.Errorf(
				runner.ErrorProviderAuthUnavailable,
				"wecom rejected the webhook credential", callErr)
		}
		return nil, nil, callErr
	}
	envelope := &wecomEnvelope{}
	if unmarshalErr := json.Unmarshal(raw, envelope); unmarshalErr != nil {
		return nil, raw, runner.Errorf(
			runner.ErrorUpstreamFailure, "provider response was not decodable", unmarshalErr)
	}
	if isValidateProbe(payload) && !probeAccepted(envelope) {
		return nil, raw, classifyEnvelope(envelope, raw, phaseValidate)
	}
	return envelope, raw, nil
}

func isValidateProbe(payload map[string]any) bool {
	// The validation probe is the empty payload round trip.
	return len(payload) == 0
}

// probeAccepted treats the structured empty-payload rejections as proof the
// webhook itself exists and responds. The accepted set mirrors the upstream
// validationSuccessCodes: 93017 is the empty-content rejection, so a webhook
// whose credential is live answers the probe with it and must connect.
func probeAccepted(envelope *wecomEnvelope) bool {
	if envelope == nil {
		return false
	}
	code, present := envelope.code()
	if !present {
		return false
	}
	switch code {
	case 0, 40008, 40058, 93017:
		return true
	default:
		return false
	}
}

// classifyEnvelope maps one structured WeCom rejection onto the closed
// runtime set, keeping the upstream business-code semantics per phase.
func classifyEnvelope(envelope *wecomEnvelope, raw json.RawMessage, phase callPhase) error {
	message := envelopeMessage(envelope, raw)
	if envelope == nil {
		return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
	}
	code, present := envelope.code()
	if !present {
		return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
	}
	switch code {
	case 45009:
		return runner.Errorf(runner.ErrorRateLimited, message, nil)
	case 40008, 40058, 93017, 93018:
		if phase == phaseValidate {
			return runner.Errorf(
				runner.ErrorProviderAuthUnavailable, message, nil)
		}
		return runner.Errorf(runner.ErrorValidation, message, nil)
	case 93000, 93004, 93019:
		// Webhook missing/removed-from-group, push disabled, and invalid bot
		// id are connection-state rejections in every phase: they persist
		// across action inputs and must not masquerade as validation.
		return runner.Errorf(
			runner.ErrorProviderAuthUnavailable, message, nil)
	case 93001, 93008:
		if phase == phaseValidate {
			return runner.Errorf(
				runner.ErrorProviderAuthUnavailable, message, nil)
		}
		return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
	default:
		return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
	}
}

func envelopeMessage(envelope *wecomEnvelope, raw json.RawMessage) string {
	if envelope != nil && envelope.message() != "" {
		return envelope.message()
	}
	fallback := strings.TrimSpace(string(raw))
	if fallback != "" {
		return fallback
	}
	return requestFailedPrefix
}

func nonEmpty(value, fallback string) string {
	if value != "" {
		return value
	}
	return fallback
}

// resolveWebhookKey accepts the bare webhook key or the full official
// webhook URL, rejecting any other origin or shape.
func resolveWebhookKey(apiKey string) (string, error) {
	trimmed := strings.TrimSpace(apiKey)
	if trimmed == "" {
		return "", runner.Errorf(
			runner.ErrorValidation, "credential field \"apiKey\" is empty", nil)
	}
	if !strings.Contains(trimmed, "://") {
		return trimmed, nil
	}
	parsed, err := url.Parse(trimmed)
	if err != nil {
		return "", runner.Errorf(
			runner.ErrorValidation, "apiKey must be a WeCom webhook key or webhook URL", nil)
	}
	if parsed.Scheme != "https" {
		return "", runner.Errorf(
			runner.ErrorValidation, "webhook URL must use https", nil)
	}
	if parsed.Host != "qyapi.weixin.qq.com" || parsed.Path != webhookPath {
		return "", runner.Errorf(
			runner.ErrorValidation, "webhook URL must be a WeCom bot webhook URL", nil)
	}
	key := strings.TrimSpace(parsed.Query().Get("key"))
	if key == "" {
		return "", runner.Errorf(
			runner.ErrorValidation, "webhook URL must include a key query parameter", nil)
	}
	return key, nil
}
