// intake.go — the inbound half of the telegram channel (V-637).
//
// Until this file, telegram was a reach and nothing else: the sink pushes an
// away message and the chat has no way to answer. That made the correction
// gesture (V-630) reachable from the web and from voice only, and the sample of
// labels skews to wherever the owner happens to be standing.
//
// Long-poll getUpdates, not a webhook. The box takes no inbound connections and
// it reaches api.telegram.org through a relay, so the direction of the
// connection has to stay outbound. The poller is off unless the telegram block
// says intake, and it accepts messages from exactly one chat.
package telegramsink

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strings"
	"time"
)

// longPollSeconds — how long telegram holds an empty getUpdates open. The HTTP
// client's own timeout has to sit above it or every poll ends as a transport
// error, which is why the poller does not reuse the sink's client.
const longPollSeconds = 25

// pollBackoff — the wait after a failed poll. The relay going down is the
// normal cause and it comes back on its own, so this is a quiet retry rather
// than an escalation.
const pollBackoff = 15 * time.Second

// Turn runs one utterance as a turn and reports the reply and the persisted
// trace id. traceID 0 means nothing persisted, and then the reply carries no
// correction buttons — there is no row for them to point at.
type Turn func(ctx context.Context, conversation, text string) (reply string, traceID int64, err error)

// Correct records the owner's correction of one turn. shouldBe empty is the
// cheap half of the gesture: wrong, target unstated.
type Correct func(ctx context.Context, traceID int64, shouldBe string) error

// Poller reads the configured chat and answers in it. One per daemon.
type Poller struct {
	sink    *Sink
	turn    Turn
	correct Correct
	hc      *http.Client
	offset  int64
}

// ValidateIntakeChatID refuses a chat id the intake half cannot use. The push
// half accepts @channelusername as a destination. The intake half cannot: an
// inbound update names its chat by numeric id, so an @-name would match nothing
// and the poller would read the chat and answer none of it. Config validation
// calls this, so the box refuses to boot rather than running a dead reach —
// NewPoller returning an error is too late, because the daemon is already up.
func ValidateIntakeChatID(chatID string) error {
	id := strings.TrimSpace(chatID)
	if id == "" {
		return errors.New("telegramsink: intake needs a chat id")
	}
	digits := strings.TrimPrefix(id, "-")
	if digits == "" || strings.TrimLeft(digits, "0123456789") != "" {
		return fmt.Errorf("telegramsink: intake needs the numeric chat id, not %s", chatID)
	}
	return nil
}

// NewPoller builds the intake half around an already-validated sink, so the
// token, the base URL and the relay are resolved in one place. turn is
// required; correct may be nil, and then the reply carries no buttons.
func NewPoller(s *Sink, turn Turn, correct Correct) (*Poller, error) {
	if s == nil {
		return nil, errors.New("telegramsink: intake needs a sink")
	}
	if turn == nil {
		return nil, errors.New("telegramsink: intake needs a turn handler")
	}
	if err := ValidateIntakeChatID(s.cfg.ChatID); err != nil {
		return nil, err
	}
	// The sink's transport already carries the relay. Only the timeout differs,
	// and it has to clear the long poll.
	hc := &http.Client{
		Timeout:   (longPollSeconds + 10) * time.Second,
		Transport: s.hc.Transport,
	}
	return &Poller{sink: s, turn: turn, correct: correct, hc: hc}, nil
}

// Run polls until the context ends. It never returns an error: a chat that
// cannot be read is a degraded reach, not a reason to stop the daemon.
func (p *Poller) Run(ctx context.Context) {
	p.discardBacklog(ctx)
	log.Printf("telegram intake: reading chat %s", p.sink.cfg.ChatID)
	for ctx.Err() == nil {
		updates, err := p.getUpdates(ctx, longPollSeconds)
		if err != nil {
			if ctx.Err() != nil {
				return
			}
			log.Printf("telegram intake: poll: %v", err)
			select {
			case <-ctx.Done():
				return
			case <-time.After(pollBackoff):
			}
			continue
		}
		for _, u := range updates {
			p.handle(ctx, u)
		}
	}
}

// discardBacklog moves the offset past whatever is already queued, without
// acting on any of it.
//
// Telegram holds undelivered updates for 24 hours, so a daemon that was down
// overnight would otherwise wake up and answer every question in order. A
// question asked eight hours ago has been answered by the owner himself or has
// stopped mattering, and a reminder set from it would land at the wrong time.
// Missing it is the safe direction.
func (p *Poller) discardBacklog(ctx context.Context) {
	// getUpdates returns at most 100 per call, so one call is not the queue. The
	// loop is bounded rather than "until empty": the timeout is 0, so an instance
	// that keeps handing back a full batch would spin, and a thousand skipped
	// messages is already a box that was down for a long time.
	skipped := 0
	for range 10 {
		updates, err := p.getUpdates(ctx, 0)
		if err != nil {
			// Not fatal. The offset stays where it was, so the first real poll sees
			// what is left and answers it late. Say so rather than hide it.
			log.Printf("telegram intake: could not skip the backlog, old messages may be answered: %v", err)
			return
		}
		skipped += len(updates)
		if len(updates) == 0 {
			break
		}
	}
	if skipped > 0 {
		log.Printf("telegram intake: skipped %d message(s) queued while the daemon was down", skipped)
	}
}

// handle dispatches one update. Anything that is neither a message from the
// owner's chat nor a callback on one of Maven's own keyboards is dropped in
// silence: a reply to a stranger confirms the bot exists and who it belongs to.
func (p *Poller) handle(ctx context.Context, u update) {
	switch {
	case u.CallbackQuery != nil:
		p.onCallback(ctx, u.CallbackQuery)
	case u.Message != nil:
		p.onMessage(ctx, u.Message)
	}
}

func (p *Poller) onMessage(ctx context.Context, m *message) {
	text := strings.TrimSpace(m.Text)
	if text == "" || !p.fromOwner(m.Chat.idString()) {
		return
	}
	// The conversation id keys the dialogue, so a clarify question asked in the
	// chat is not answered by an utterance typed on the web.
	reply, traceID, err := p.turn(ctx, "telegram:"+m.Chat.idString(), text)
	if err != nil {
		log.Printf("telegram intake: turn: %v", err)
		return
	}
	if strings.TrimSpace(reply) == "" {
		return
	}
	if err := p.send(ctx, reply, p.correctionKeyboard(traceID)); err != nil {
		log.Printf("telegram intake: reply: %v", err)
	}
}

// onCallback handles a tap on a correction button. Every path from the owner
// answers the callback: telegram spins a clock on the button until it is
// answered, and an unanswered tap reads as a gesture that was dropped. A tap
// from anyone else gets silence, the same as a message from a stranger.
func (p *Poller) onCallback(ctx context.Context, cb *callbackQuery) {
	if !p.fromOwner(cb.Message.Chat.idString()) {
		return
	}
	traceID, target, kind := parseCallback(cb.Data)
	if kind == callbackUnknown || p.correct == nil {
		p.answerCallback(ctx, cb.ID, "")
		return
	}
	// A tap on "не то" only opens the second row. Nothing is written yet: the
	// target is worth much more than the negative, so he gets the chance to name
	// it before the gesture is spent.
	if kind == callbackAskTarget {
		p.answerCallback(ctx, cb.ID, "")
		if err := p.editKeyboard(ctx, cb.Message.Chat.idString(), cb.Message.MessageID, targetKeyboard(traceID)); err != nil {
			log.Printf("telegram intake: open the target row: %v", err)
		}
		return
	}
	if err := p.correct(ctx, traceID, target); err != nil {
		log.Printf("telegram intake: correct turn %d: %v", traceID, err)
		p.answerCallback(ctx, cb.ID, "не записалось")
		return
	}
	p.answerCallback(ctx, cb.ID, "записала")
	// The buttons come off, because the correction is given and a live keyboard
	// on an answered turn invites correcting it twice.
	if err := p.editKeyboard(ctx, cb.Message.Chat.idString(), cb.Message.MessageID, nil); err != nil {
		log.Printf("telegram intake: clear the keyboard: %v", err)
	}
}

// fromOwner — one chat, and it is the one the sink already sends to. Telegram
// chat ids are not guessable, but they are also not secret: they travel in
// every forwarded message. So this is the whole authorisation and it is an
// allowlist of one.
func (p *Poller) fromOwner(chatID string) bool {
	return chatID != "" && chatID == p.cfgChatID()
}

func (p *Poller) cfgChatID() string { return strings.TrimSpace(p.sink.cfg.ChatID) }
