// 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 runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
// with a dedicated user (write-only to maven-* topics) — the credential is a
// delivery-config secret, not a db key; a popped ntfy sink can push spam to
// your phone, nothing else. matches the module key-isolation invariant: the
// sink never holds the sqlcipher key.
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 {
	BaseURL  string        // e.g. http://127.0.0.1:8085 (no trailing path)
	Topic    string        // e.g. maven (all maven notifications land here)
	Username string        // basic auth; empty = anonymous (won't work with deny-all)
	Password string        // basic auth
	Timeout  time.Duration // per-request; 0 = DefaultTimeout
}

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
}

// New validates the config and builds the sink. BaseURL and Topic are
// required; auth is optional (but deny-all servers reject unauthed publishes).
func New(cfg Config) (*Sink, error) {
	if cfg.BaseURL == "" {
		return nil, fmt.Errorf("ntfysink: BaseURL is required")
	}
	if _, err := url.Parse(cfg.BaseURL); err != nil {
		return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err)
	}
	if cfg.Topic == "" {
		return nil, fmt.Errorf("ntfysink: Topic is required")
	}
	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.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))
		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
}
