// Package telegramsink implements delivery.Sink for the telegram push channel.
//
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
// at 2am routes to telegram, repeat til ack." the message body is the
// delivery.AwayMessage — the minimal-body rule from the spec ("disk low on
// homesrv," not detail; no shoulder-surf exfil through the relay). the
// dispatcher already strips detail off away sendables; the sink uses the same
// helper so it can't leak the body on its own either. additionally,
// protect_content=true is passed on every send so the message can't be
// forwarded out of the chat — locks the minimal body further.
//
// telegram's bot API is region-restricted for this homesrv — direct egress to
// api.telegram.org is unreliable. the spec's "away channels leave the box —
// through your relay" maps here to a Proxy config field (HTTP/HTTPS/SOCKS5).
// stdlib net/http Transport.Proxy supports all three; the daemon wires the
// relay URL from config. the bot token is a delivery-config secret (not a db
// key — a popped telegram sink can push spam to your chat, nothing else;
// matches the module key-isolation invariant: the sink never holds the
// sqlcipher key).
//
// repeat-til-ack is driven by the dispatcher + AckTracker (delivery/ack.go +
// dispatcher.RepeatUnacked), NOT by the sink. the sink is fire-and-forget per
// call — telegram has no priority/insistence field analogous to ntfy's 1–5;
// the repeat mechanism IS the insistence, re-sending on the dispatcher's tick
// until MarkAcked.
package telegramsink

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/kami/maven/internal/delivery"
)

// DefaultBaseURL — telegram bot API. overridable in Config (e.g. for a
// self-hosted bridge that mirrors the API shape and handles the region cut
// itself); the proxy is the normal path, the BaseURL override is the fallback.
const DefaultBaseURL = "https://api.telegram.org"

const DefaultTimeout = 10 * time.Second

// Config — telegram bot API publish config. the daemon wires this from its
// config file; the bot token + chat id live in the daemon's config (or a
// systemd credential), never in the binary.
type Config struct {
	// BotToken — the telegram bot token from BotFather. required. sent in the
	// URL path (the only place telegram accepts it), not in the body.
	BotToken string `json:"bot_token"`

	// ChatID — destination chat. may be a numeric user/group id (sent as a
	// JSON number) or @channelusername (sent as a JSON string). required.
	ChatID string `json:"chat_id"`

	// BaseURL — telegram API base. empty = DefaultBaseURL. override to point
	// at a self-hosted API bridge if the proxy path isn't used.
	BaseURL string `json:"base_url,omitempty"`

	// Proxy — URL of an HTTP/HTTPS/SOCKS5 relay used to reach the telegram
	// API (region-restricted direct egress). empty = direct (won't work from
	// the homesrv without a relay; kept configurable for tests + future
	// topology change).
	Proxy string `json:"proxy,omitempty"`

	// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
	// tick loop.
	Timeout time.Duration
}

// Sink — implements delivery.Sink via the telegram bot sendMessage API. one
// POST per Send; no retry (the dispatcher + daemon decide retry policy). the
// repeat-til-ack clock is driven by the dispatcher calling Send again each
// interval — the sink itself is stateless.
type Sink struct {
	cfg  Config
	hc   *http.Client
	base string // resolved BaseURL, no trailing slash
}

// New validates the config and builds the sink. BotToken and ChatID are
// required; Proxy and BaseURL are optional.
func New(cfg Config) (*Sink, error) {
	if cfg.BotToken == "" {
		return nil, fmt.Errorf("telegramsink: BotToken is required")
	}
	if cfg.ChatID == "" {
		return nil, fmt.Errorf("telegramsink: ChatID is required")
	}
	base := cfg.BaseURL
	if base == "" {
		base = DefaultBaseURL
	}
	if _, err := url.Parse(base); err != nil {
		return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err)
	}
	if cfg.Proxy != "" {
		if _, err := url.Parse(cfg.Proxy); err != nil {
			return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err)
		}
	}
	to := cfg.Timeout
	if to == 0 {
		to = DefaultTimeout
	}
	transport := http.DefaultTransport.(*http.Transport).Clone()
	if cfg.Proxy != "" {
		pu, _ := url.Parse(cfg.Proxy) // parsed+checked above
		transport.Proxy = http.ProxyURL(pu)
	}
	return &Sink{
		cfg:  cfg,
		base: strings.TrimRight(base, "/"),
		hc:   &http.Client{Timeout: to, Transport: transport},
	}, nil
}

// sendMessageReq — the subset of sendMessage params maven uses. JSON-encoded
// as the request body. chat_id accepts number or string; go json tags keep
// both shapes (channel usernames are strings, user ids are numbers — send
// whatever ChatID was configured as).
type sendMessageReq struct {
	ChatID              string `json:"chat_id"`
	Text                string `json:"text"`
	DisableNotification bool   `json:"disable_notification"` // false = ring (always — these are alarms)
	ProtectContent      bool   `json:"protect_content"`      // true = no forwarding out of chat
}

// telegramResp — the shape telegram returns. ok=false on logical error with
// error_code + description; ok=true with result on success (result contents
// not needed by the sink).
type telegramResp struct {
	Ok          bool   `json:"ok"`
	ErrorCode   int    `json:"error_code,omitempty"`
	Description string `json:"description,omitempty"`
}

// Send publishes one message to the configured telegram chat. the body is the
// minimal away message (never the full body). protect_content=true so even
// that can't be forwarded onward by the user or a chat observer — locks the
// minimal-body rule at the channel's own last mile.
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
	// never fall back to d.Body: telegram leaves the box, so an empty summary
	// gets a generic line instead of the full detail.
	body := delivery.AwayMessage(d)

	payload := sendMessageReq{
		ChatID:              s.cfg.ChatID,
		Text:                body,
		DisableNotification: false, // maven sends to telegram only on sev4/sev3/reminder — all want to ring
		ProtectContent:      true,  // no forwarding out — locks minimal body at the channel last mile
	}
	pb, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("telegramsink: marshal: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.sendMessageURL(), bytes.NewReader(pb))
	if err != nil {
		return fmt.Errorf("telegramsink: build request: %w", s.redact(err))
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := s.hc.Do(req)
	if err != nil {
		return fmt.Errorf("telegramsink: sendMessage: %w", s.redact(err))
	}
	defer resp.Body.Close()
	rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))

	// telegram returns 200 with ok=true on success; non-2xx with ok=false +
	// error_code + description on failure. parse the body either way so a 200
	// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
	var tr telegramResp
	if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok {
		return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
	}
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
	}
	return nil
}

// sendMessageURL — the bot API path. the token is in the URL path
// (https://api.telegram.org/bot<token>/sendMessage); telegram does not accept
// it anywhere else. the URL is built per-send from the resolved base and never
// stored, but it does end up inside transport errors — see redact.
func (s *Sink) sendMessageURL() string {
	return s.base + "/bot" + s.cfg.BotToken + "/sendMessage"
}

// tokenPlaceholder — what a redacted token reads as in an error. Recognisable
// on sight, so nobody reads a redacted URL as a malformed one.
const tokenPlaceholder = "<redacted>"

// redact strips the bot token out of a transport error before it becomes a
// returned error, and from there a log line.
//
// This is not hypothetical. net/http wraps every transport failure in
// *url.Error, whose Error() prints the full request URL, and the token is IN
// that URL because telegram accepts it nowhere else. On 2026-08-01 homesrv
// could not reach api.telegram.org, so the retry wrote the whole bot token
// into the daemon log once a minute for as long as the network stayed down.
// The token lives in deploy/telegram.env specifically to stay out of the repo;
// putting it in `docker compose logs` undoes that.
//
// The structural case rewrites url.Error.URL and keeps the error's type, so
// callers matching on *url.Error still work. Anything else falls back to
// scrubbing the rendered message, which loses the type but cannot leak.
//
// There is deliberately no minimum-length guard. A one-character token would
// make this replace every occurrence of that character in the message, which
// is ugly; leaking a short token is worse. New already refuses an empty one.
func (s *Sink) redact(err error) error {
	if err == nil {
		return err
	}
	var ue *url.Error
	if errors.As(err, &ue) {
		clean := *ue
		clean.URL = strings.ReplaceAll(clean.URL, s.cfg.BotToken, tokenPlaceholder)
		err = &clean
	}
	if msg := strings.ReplaceAll(err.Error(), s.cfg.BotToken, tokenPlaceholder); msg != err.Error() {
		return errors.New(msg)
	}
	return err
}
