package dingtalkbot

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"net/url"
	"strconv"
	"strings"
	"time"

	"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,
		ActionSendLinkMessage:       handleSendLinkMessage,
		ActionSendMarkdownMessage:   handleSendMarkdownMessage,
		ActionSendActionCardMessage: handleSendActionCardMessage,
		ActionSendFeedCardMessage:   handleSendFeedCardMessage,
	}
}

const (
	webhookPath         = "/robot/send"
	requestFailedPrefix = "dingtalk 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"
)

// dingtalkEnvelope is the normalized DingTalk webhook response.
type dingtalkEnvelope struct {
	ErrCode *int64 `json:"errcode"`
	ErrMsg  string `json:"errmsg"`
}

// atInput captures the flattened mention fields with presence fidelity: an
// absent field must stay absent on the wire, an explicit empty value stays
// explicit.
type atInput struct {
	AtMobiles *[]string `json:"atMobiles"`
	AtUserIDs *[]string `json:"atUserIds"`
	IsAtAll   *bool     `json:"isAtAll"`
}

// atPayload builds the compact at object, omitting it entirely when no
// mention field was provided.
func (at *atInput) atPayload() map[string]any {
	if at == nil {
		return nil
	}
	payload := map[string]any{}
	if at.AtMobiles != nil {
		payload["atMobiles"] = *at.AtMobiles
	}
	if at.AtUserIDs != nil {
		payload["atUserIds"] = *at.AtUserIDs
	}
	if at.IsAtAll != nil {
		payload["isAtAll"] = *at.IsAtAll
	}
	if len(payload) == 0 {
		return nil
	}
	return payload
}

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
}

func handleSendTextMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		MsgUUID   string    `json:"msgUuid"`
		Content   string    `json:"content"`
		AtMobiles *[]string `json:"atMobiles"`
		AtUserIDs *[]string `json:"atUserIds"`
		IsAtAll   *bool     `json:"isAtAll"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	at := atInput{AtMobiles: parsed.AtMobiles, AtUserIDs: parsed.AtUserIDs, IsAtAll: parsed.IsAtAll}
	payload := map[string]any{
		"msgtype": "text",
		"text":    map[string]any{"content": parsed.Content},
	}
	applyOptional(payload, "msgUuid", parsed.MsgUUID)
	applyOptional(payload, "at", at.atPayload())
	return sendWebhookMessage(ctx, client, credentials, payload)
}

func handleSendLinkMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		MsgUUID    string `json:"msgUuid"`
		Title      string `json:"title"`
		Text       string `json:"text"`
		MessageURL string `json:"messageUrl"`
		PicURL     string `json:"picUrl"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	link := map[string]any{
		"title":      parsed.Title,
		"text":       parsed.Text,
		"messageUrl": parsed.MessageURL,
	}
	applyOptional(link, "picUrl", parsed.PicURL)
	payload := map[string]any{
		"msgtype": "link",
		"link":    link,
	}
	applyOptional(payload, "msgUuid", parsed.MsgUUID)
	return sendWebhookMessage(ctx, client, credentials, payload)
}

func handleSendMarkdownMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		MsgUUID   string    `json:"msgUuid"`
		Title     string    `json:"title"`
		Text      string    `json:"text"`
		AtMobiles *[]string `json:"atMobiles"`
		AtUserIDs *[]string `json:"atUserIds"`
		IsAtAll   *bool     `json:"isAtAll"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	at := atInput{AtMobiles: parsed.AtMobiles, AtUserIDs: parsed.AtUserIDs, IsAtAll: parsed.IsAtAll}
	payload := map[string]any{
		"msgtype":  "markdown",
		"markdown": map[string]any{"title": parsed.Title, "text": parsed.Text},
	}
	applyOptional(payload, "msgUuid", parsed.MsgUUID)
	applyOptional(payload, "at", at.atPayload())
	return sendWebhookMessage(ctx, client, credentials, payload)
}

func handleSendActionCardMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		CardMode          string            `json:"cardMode"`
		MsgUUID           string            `json:"msgUuid"`
		Title             string            `json:"title"`
		Text              string            `json:"text"`
		SingleTitle       string            `json:"singleTitle"`
		SingleURL         string            `json:"singleUrl"`
		Buttons           []actionCardInput `json:"buttons"`
		ButtonOrientation string            `json:"buttonOrientation"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	var actionCard map[string]any
	if parsed.CardMode == "single" {
		actionCard = map[string]any{
			"title":       parsed.Title,
			"text":        parsed.Text,
			"singleTitle": parsed.SingleTitle,
			"singleURL":   parsed.SingleURL,
		}
	} else {
		buttons := make([]map[string]any, 0, len(parsed.Buttons))
		for _, button := range parsed.Buttons {
			buttons = append(buttons, map[string]any{
				"title":     button.Title,
				"actionURL": button.ActionURL,
			})
		}
		actionCard = map[string]any{
			"title": parsed.Title,
			"text":  parsed.Text,
			"btns":  buttons,
		}
	}
	applyOptional(actionCard, "btnOrientation", mapButtonOrientation(parsed.ButtonOrientation))
	payload := map[string]any{
		"msgtype":    "actionCard",
		"actionCard": actionCard,
	}
	applyOptional(payload, "msgUuid", parsed.MsgUUID)
	return sendWebhookMessage(ctx, client, credentials, payload)
}

type actionCardInput struct {
	Title     string `json:"title"`
	ActionURL string `json:"actionUrl"`
}

func mapButtonOrientation(value string) string {
	switch value {
	case "vertical":
		return "0"
	case "horizontal":
		return "1"
	default:
		return ""
	}
}

func handleSendFeedCardMessage(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	var parsed struct {
		MsgUUID string         `json:"msgUuid"`
		Links   []feedCardLink `json:"links"`
	}
	if err := decodeInput(input, &parsed); err != nil {
		return nil, err
	}
	links := make([]map[string]any, 0, len(parsed.Links))
	for _, link := range parsed.Links {
		links = append(links, map[string]any{
			"title":      link.Title,
			"messageURL": link.MessageURL,
			"picURL":     link.PicURL,
		})
	}
	payload := map[string]any{
		"msgtype":  "feedCard",
		"feedCard": map[string]any{"links": links},
	}
	applyOptional(payload, "msgUuid", parsed.MsgUUID)
	return sendWebhookMessage(ctx, client, credentials, payload)
}

type feedCardLink struct {
	Title      string `json:"title"`
	MessageURL string `json:"messageUrl"`
	PicURL     string `json:"picUrl"`
}

// applyOptional sets key only for a non-empty string or non-nil value,
// mirroring the upstream compactObject drops.
func applyOptional(payload map[string]any, key string, value any) {
	switch typed := value.(type) {
	case string:
		if typed != "" {
			payload[key] = typed
		}
	case map[string]any:
		if typed != nil {
			payload[key] = typed
		}
	}
}

// sendWebhookMessage executes one signed 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)
	}
	if envelope.ErrCode == nil || *envelope.ErrCode != 0 {
		return nil, classifyEnvelope(envelope, raw, phaseExecute)
	}
	return json.Marshal(map[string]any{
		"errcode": *envelope.ErrCode,
		"errmsg":  nonEmpty(envelope.ErrMsg, "ok"),
	})
}

// callWebhook performs the shared webhook round trip: resolve the token,
// append the signature query when configured, and classify failures for the
// validation probe path.
func callWebhook(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	payload map[string]any,
) (*dingtalkEnvelope, json.RawMessage, error) {
	accessToken, err := resolveAccessToken(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("access_token", accessToken)
	if secret := strings.TrimSpace(credentials[SigningSecretField]); secret != "" {
		timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
		query.Set("timestamp", timestamp)
		query.Set("sign", buildSignature(timestamp, secret))
	}
	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,
				"dingtalk rejected the webhook credential", callErr)
		}
		return nil, nil, callErr
	}
	envelope := &dingtalkEnvelope{}
	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 {
	return payload["msgtype"] == "__validation_probe__"
}

// probeAccepted treats the invalid-msgtype rejection (and the keyword filter
// miss) as proof the webhook itself and the signature were accepted.
func probeAccepted(envelope *dingtalkEnvelope) bool {
	if envelope == nil || envelope.ErrCode == nil {
		return false
	}
	switch *envelope.ErrCode {
	case 40035, 400105:
		return true
	case 310000:
		return strings.Contains(envelope.ErrMsg, "keywords not in content")
	default:
		return false
	}
}

// classifyEnvelope maps one structured DingTalk rejection onto the closed
// runtime set, keeping the upstream business-code semantics per phase.
func classifyEnvelope(envelope *dingtalkEnvelope, raw json.RawMessage, phase callPhase) error {
	message := envelopeMessage(envelope, raw)
	if envelope == nil || envelope.ErrCode == nil {
		return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
	}
	switch *envelope.ErrCode {
	case 410100:
		return runner.Errorf(runner.ErrorRateLimited, message, nil)
	case 400013, 400101, 400102, 400106:
		return runner.Errorf(
			runner.ErrorProviderAuthUnavailable, message, nil)
	case 40035, 400105, 430101, 430102, 430103, 430104:
		if phase == phaseValidate {
			return runner.Errorf(
				runner.ErrorProviderAuthUnavailable, message, nil)
		}
		return runner.Errorf(runner.ErrorValidation, message, nil)
	case 310000:
		if phase == phaseValidate {
			return runner.Errorf(
				runner.ErrorProviderAuthUnavailable, message, nil)
		}
		// 310000 carries several unrelated rejections behind one code;
		// only the keyword filter is action-input validation. Signature
		// and IP-whitelist failures belong to the connection, anything
		// unrecognized fails closed as upstream.
		switch {
		case strings.Contains(envelope.ErrMsg, "sign not match"),
			strings.Contains(envelope.ErrMsg, "invalid timestamp"):
			return runner.Errorf(
				runner.ErrorProviderAuthUnavailable, message, nil)
		case strings.Contains(envelope.ErrMsg, "keywords not in content"):
			return runner.Errorf(runner.ErrorValidation, message, nil)
		case strings.Contains(envelope.ErrMsg, "not in whitelist"):
			return runner.Errorf(
				runner.ErrorProviderAuthUnavailable, message, nil)
		default:
			return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
		}
	default:
		return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
	}
}

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

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

// buildSignature stamps the DingTalk signature: base64 of HMAC-SHA256 keyed
// by the secret over "<timestamp>\n<secret>".
func buildSignature(timestamp, secret string) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "\n" + secret))
	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

// resolveAccessToken accepts the bare webhook access token or the full
// official webhook URL, rejecting any other origin or shape.
func resolveAccessToken(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 DingTalk webhook access token or webhook URL", nil)
	}
	if parsed.Scheme != "https" || parsed.User != nil {
		return "", runner.Errorf(
			runner.ErrorValidation,
			"webhook URL must use https and must not include credentials", nil)
	}
	if parsed.Host != "oapi.dingtalk.com" || parsed.Path != webhookPath {
		return "", runner.Errorf(
			runner.ErrorValidation, "webhook URL must be a DingTalk custom bot webhook URL", nil)
	}
	accessToken := strings.TrimSpace(parsed.Query().Get("access_token"))
	if accessToken == "" {
		return "", runner.Errorf(
			runner.ErrorValidation, "webhook URL must include an access_token query parameter", nil)
	}
	return accessToken, nil
}
