// Package ntfysink implements delivery.Sink for the ntfy push channel.
//
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
// nudges when present (alongside voice), and reminders when away. the
// message body is 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.
//
// ntfy is a self-hosted server with deny-all auth — ntfy.kvmx.ru as of
// 07-08-2026, reached directly, not through the socks relay telegram needs.
// maven publishes with a write-only token scoped to its own topic; the
// credential is a delivery-config secret, not a db key. a popped ntfy sink
// can push spam to that one topic, nothing else — it cannot read the topic
// back and it never holds the sqlcipher key.
//
// this is the second reach, and the reason there is one is that telegram was
// the only one (V-649). telegram needs api.telegram.org, a socks relay on the
// host and a matching ufw rule, three things in series that have each broken
// once. ntfy shares none of them.
package ntfysink

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

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

// Config — ntfy publish config. the daemon wires this from its config file;
// the credential lives in the daemon's config (or a systemd credential),
// never in the binary.
type Config struct {
	// Disabled keeps a written endpoint explicitly dark. This is distinct from
	// an expanded-empty credential: the latter is a configuration error, while
	// this flag records an operator decision to use another delivery reach until
	// credentials are provisioned.
	Disabled bool `json:"disabled,omitempty"`

	// BaseURL — the ntfy server, no trailing path. Required.
	BaseURL string `json:"base_url"`

	// Topic — where maven publishes. Required. All maven notifications land
	// on this one topic; severity rides the Priority header, not the topic.
	Topic string `json:"topic"`

	// Token — an ntfy access token, sent as a bearer. This is the preferred
	// credential: ntfy scopes a token to a topic and to write-only, so a
	// popped sink can push to this one topic and cannot read it back or
	// touch another. Revoking it does not disturb a password anyone else
	// uses. Mutually exclusive with Username.
	Token string `json:"token,omitempty"`

	// Username, Password — basic auth, for a server that has no tokens.
	// Empty username means no credential is sent at all, which a deny-all
	// server rejects.
	Username string `json:"username,omitempty"`
	Password string `json:"password,omitempty"`

	// Timeout — per-request; 0 = DefaultTimeout. A dead server must not hang
	// the tick loop.
	Timeout time.Duration `json:"-"`
}

const DefaultTimeout = 10 * time.Second

// Sink — implements delivery.Sink via ntfy HTTP publish. one POST per Send.
// no retry (the dispatcher + daemon decide retry policy); no streaming.
type Sink struct {
	cfg Config
	hc  *http.Client
}

// Validate checks one configuration without constructing a client. A disabled
// block is the only state in which credentials may be empty. Maven's ntfy
// reach is private, and accepting an accidental anonymous configuration turns
// a missing environment variable into an endless 403 retry loop.
func Validate(cfg Config) error {
	if cfg.Disabled {
		return nil
	}
	if strings.TrimSpace(cfg.BaseURL) == "" {
		return fmt.Errorf("ntfysink: BaseURL is required while enabled")
	}
	if _, err := url.Parse(cfg.BaseURL); err != nil {
		return fmt.Errorf("ntfysink: bad BaseURL: %w", err)
	}
	if strings.TrimSpace(cfg.Topic) == "" {
		return fmt.Errorf("ntfysink: Topic is required while enabled")
	}
	// Refuse rather than pick. Two credentials configured means someone
	// intended one of them, and guessing which would send the other nowhere
	// and leave a working config that is not the one they wrote.
	if strings.TrimSpace(cfg.Token) != "" && (strings.TrimSpace(cfg.Username) != "" || cfg.Password != "") {
		return fmt.Errorf("ntfysink: set Token or Username/Password, not both")
	}
	if strings.TrimSpace(cfg.Token) == "" {
		if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
			return fmt.Errorf("ntfysink: Token or Username/Password is required while enabled")
		}
	}
	return nil
}

// New validates the config and builds the sink. Disabled configs belong at the
// daemon wiring boundary and cannot accidentally become live sinks.
func New(cfg Config) (*Sink, error) {
	if cfg.Disabled {
		return nil, fmt.Errorf("ntfysink: config is disabled")
	}
	if err := Validate(cfg); err != nil {
		return nil, err
	}
	to := cfg.Timeout
	if to == 0 {
		to = DefaultTimeout
	}
	return &Sink{
		cfg: cfg,
		hc:  &http.Client{Timeout: to},
	}, nil
}

// Send publishes one notification to ntfy. the body is the minimal away
// message (never the full body); Title is "maven" (consistent sender identity
// on the lock screen — the content is in the body). Priority maps from severity/kind so
// the phone client can ring differently for an alarm vs a soft ops nudge.
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
	// never fall back to d.Body: ntfy leaves the box, so an empty summary gets
	// a generic line instead of the full detail.
	body := delivery.AwayMessage(d)

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
	if err != nil {
		return fmt.Errorf("ntfysink: build request: %w", err)
	}
	req.Header.Set("Title", "maven")
	req.Header.Set("Priority", priorityFor(d).String())
	if s.cfg.Token != "" {
		req.Header.Set("Authorization", "Bearer "+s.cfg.Token)
	} else if s.cfg.Username != "" {
		req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
	}

	resp, err := s.hc.Do(req)
	if err != nil {
		return fmt.Errorf("ntfysink: publish: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
			return fmt.Errorf("%w: ntfysink: credentials rejected (%d): %s",
				delivery.ErrPermanent, resp.StatusCode, strings.TrimSpace(string(rb)))
		}
		return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
	}
	return nil
}

func (s *Sink) topicURL() string {
	return strings.TrimRight(s.cfg.BaseURL, "/") + "/" + s.cfg.Topic
}

// ntfyPriority — ntfy's 1–5 priority scale.
type ntfyPriority int

const (
	prioMin     ntfyPriority = 1
	prioLow     ntfyPriority = 2
	prioDefault ntfyPriority = 3
	prioHigh    ntfyPriority = 4
	prioMax     ntfyPriority = 5
)

func (p ntfyPriority) String() string { return fmt.Sprintf("%d", int(p)) }

// priorityFor — maps maven's (kind, severity) to ntfy's 1–5.
//
// reminders are user-stated intent ("wake me 7") → high (4); they bypassed the
// gate to reach you, make them ring. sev4 (ops hard, present — away goes to
// telegram) → max (5): a disk-fire alarm that also pushes to your watch. sev3
// (ops soft, away) → high (4): it held through away for a reason. care nudges
// never reach ntfy (they drop on away), so no sev1–2 mapping here.
func priorityFor(d delivery.Sendable) ntfyPriority {
	if d.Kind == delivery.KindReminder {
		return prioHigh
	}
	if d.Severity >= loop.Sev4 {
		return prioMax
	}
	if d.Severity == loop.Sev3 {
		return prioHigh
	}
	return prioDefault
}
