// botapi.go — the telegram bot API calls the intake half makes, and the inbound
// shapes it reads (V-637). Split out of intake.go so the poller reads as the
// policy it is, with the wire in one place under it.
package telegramsink

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
)

// getUpdates long-polls. The offset is telegram's own acknowledgement: asking
// for lastSeen+1 is what drops everything before it from the queue, so an
// update is handled once even across a restart.
func (p *Poller) getUpdates(ctx context.Context, timeoutSec int) ([]update, error) {
	body, err := json.Marshal(map[string]any{
		"offset":          p.offset,
		"timeout":         timeoutSec,
		"allowed_updates": []string{"message", "callback_query"},
	})
	if err != nil {
		return nil, err
	}
	var env struct {
		telegramResp
		Result []update `json:"result"`
	}
	if err := p.call(ctx, "getUpdates", body, &env); err != nil {
		return nil, err
	}
	for _, u := range env.Result {
		if u.UpdateID >= p.offset {
			p.offset = u.UpdateID + 1
		}
	}
	return env.Result, nil
}

func (p *Poller) send(ctx context.Context, text string, kb *inlineKeyboard) error {
	body, err := json.Marshal(sendMessageReq{
		ChatID: p.cfgChatID(),
		Text:   text,
		// A reply to something he just typed is not an alarm, but it is still his
		// own data in a third party's chat, so it stays unforwardable like the
		// away messages the sink pushes.
		ProtectContent: true,
		ReplyMarkup:    kb,
	})
	if err != nil {
		return err
	}
	return p.call(ctx, "sendMessage", body, nil)
}

// answerCallback stops the clock on the tapped button. text empty is a silent
// acknowledgement; anything else shows as a toast.
func (p *Poller) answerCallback(ctx context.Context, id, text string) {
	body, err := json.Marshal(map[string]any{"callback_query_id": id, "text": text})
	if err != nil {
		return
	}
	if err := p.call(ctx, "answerCallbackQuery", body, nil); err != nil {
		log.Printf("telegram intake: answer callback: %v", err)
	}
}

// editKeyboard replaces the buttons under a message the bot sent. kb nil takes
// them off.
func (p *Poller) editKeyboard(ctx context.Context, chatID string, messageID int64, kb *inlineKeyboard) error {
	payload := map[string]any{"chat_id": chatID, "message_id": messageID}
	if kb != nil {
		payload["reply_markup"] = kb
	} else {
		payload["reply_markup"] = inlineKeyboard{Rows: [][]inlineButton{}}
	}
	body, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	return p.call(ctx, "editMessageReplyMarkup", body, nil)
}

// call posts one bot API method and checks the envelope. out may be nil when
// only the ok flag matters. Every error goes through the sink's redaction: the
// token is in the URL path because telegram accepts it nowhere else, and
// net/http prints that URL in transport errors.
func (p *Poller) call(ctx context.Context, method string, body []byte, out any) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		p.sink.base+"/bot"+p.sink.cfg.BotToken+"/"+method, bytes.NewReader(body))
	if err != nil {
		return p.sink.redact(err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := p.hc.Do(req)
	if err != nil {
		return fmt.Errorf("telegramsink: %s: %w", method, p.sink.redact(err))
	}
	defer resp.Body.Close()
	rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxIntakeRespBytes))

	var tr telegramResp
	if err := json.Unmarshal(rb, &tr); err != nil {
		return fmt.Errorf("telegramsink: %s: %d with a body that is not the bot API envelope: %s",
			method, resp.StatusCode, snippet(rb))
	}
	if !tr.Ok {
		return fmt.Errorf("telegramsink: %s: telegram returned error %d: %s",
			method, tr.ErrorCode, strings.TrimSpace(tr.Description))
	}
	if out == nil {
		return nil
	}
	if err := json.Unmarshal(rb, out); err != nil {
		return fmt.Errorf("telegramsink: %s: decode result: %w", method, err)
	}
	return nil
}

// maxIntakeRespBytes — a getUpdates batch carries up to 100 messages, so the
// send path's cap is too small here. Still bounded: the body is wire-controlled
// and a relay sits in front of it.
const maxIntakeRespBytes = 4 << 20

// The inbound shapes, cut to what the poller reads.
type update struct {
	UpdateID      int64          `json:"update_id"`
	Message       *message       `json:"message,omitempty"`
	CallbackQuery *callbackQuery `json:"callback_query,omitempty"`
}

type message struct {
	MessageID int64  `json:"message_id"`
	Chat      chat   `json:"chat"`
	Text      string `json:"text"`
}

type callbackQuery struct {
	ID      string  `json:"id"`
	Data    string  `json:"data"`
	Message message `json:"message"`
}

// chat — the id arrives as a JSON number for a user and a string for a channel,
// and the config holds whichever was written. json.Number keeps both without
// choosing.
type chat struct {
	ID       json.Number `json:"id"`
	Username string      `json:"username,omitempty"`
}

func (c chat) idString() string {
	if s := c.ID.String(); s != "" {
		return s
	}
	if c.Username != "" {
		return "@" + c.Username
	}
	return ""
}

// inlineKeyboard — the reply_markup shape. Rows of buttons, each carrying
// callback data.
type inlineKeyboard struct {
	Rows [][]inlineButton `json:"inline_keyboard"`
}

type inlineButton struct {
	Text string `json:"text"`
	Data string `json:"callback_data"`
}
