package apprise

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"drivervault/apiserver/internal/plugins"
)

// ---- descriptor & config -----------------------------------------------------

func TestDescriptor(t *testing.T) {
	d := (&Plugin{}).Descriptor()
	if d.Name != "apprise" {
		t.Fatalf("name = %q, want apprise", d.Name)
	}
	if d.Kind != plugins.KindBuiltin {
		t.Fatalf("kind = %q, want builtin", d.Kind)
	}
	if d.Category != plugins.CategoryNotifications {
		t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryNotifications)
	}

	actions := map[string]bool{}
	for _, c := range d.Capabilities {
		actions[c.ID] = true
	}
	for _, want := range []string{"notify", "targets", "services", "status"} {
		if !actions[want] {
			t.Errorf("capability %q should be advertised", want)
		}
	}

	fields := map[string]plugins.ConfigField{}
	for _, f := range d.ConfigFields {
		fields[f.Key] = f
	}
	for _, k := range []string{"baseUrl", "configKey", "urls", "tag", "format", "username", "password", "timeout"} {
		if _, ok := fields[k]; !ok {
			t.Errorf("config field %q should be present", k)
		}
	}
	// There is no per-user cascade behind this connector: without an address it
	// cannot work at all, so the panel must refuse to enable it blank.
	if !fields["baseUrl"].Required {
		t.Error("baseUrl must be required — nothing else can supply it")
	}
	// Apprise URLs embed the credentials of every service they address.
	if !fields["urls"].Secret || !fields["password"].Secret {
		t.Error("urls and password must both be marked secret")
	}
	if fields["configKey"].Required || fields["urls"].Required {
		t.Error("the destination is either a key or URLs; neither may be required on its own")
	}
	if got := fields["format"].Type; got != "select" {
		t.Errorf("format type = %q, want select", got)
	}
	if got := fields["tag"].Default; got != tagAll {
		t.Errorf("tag default = %q, want %q", got, tagAll)
	}
}

func TestNormalizeBaseURL(t *testing.T) {
	cases := []struct {
		in, want string
		wantErr  bool
	}{
		{in: "", want: ""},
		{in: "http://apprise:8000", want: "http://apprise:8000"},
		{in: "  http://apprise:8000/  ", want: "http://apprise:8000"},
		{in: "https://apprise.example.com", want: "https://apprise.example.com"},
		// A bare host is the common shape on an internal network.
		{in: "10.2.1.10:8000", want: "http://10.2.1.10:8000"},
		// A reverse proxy may publish the API under a path prefix.
		{in: "https://home.example.com/apprise/", want: "https://home.example.com/apprise"},
		{in: "ftp://apprise", wantErr: true},
		{in: "http://", wantErr: true},
	}
	for _, c := range cases {
		got, err := normalizeBaseURL(c.in)
		if c.wantErr {
			if err == nil {
				t.Errorf("normalizeBaseURL(%q) = %q, want an error", c.in, got)
			}
			continue
		}
		if err != nil {
			t.Errorf("normalizeBaseURL(%q): %v", c.in, err)
			continue
		}
		if got != c.want {
			t.Errorf("normalizeBaseURL(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

func TestInitRejectsUnusableConfigKey(t *testing.T) {
	p := &Plugin{}
	err := p.Init(context.Background(), map[string]string{
		"baseUrl": "http://apprise:8000", "configKey": "not a key",
	})
	if err == nil {
		t.Fatal("a config key with a space should be rejected at Init")
	}
	if !strings.Contains(err.Error(), "not usable") {
		t.Errorf("error should say what is wrong with the key, got: %v", err)
	}
}

func TestParseTimeoutClamps(t *testing.T) {
	cases := map[string]time.Duration{
		"":     defaultTimeout,
		"abc":  defaultTimeout,
		"0":    defaultTimeout,
		"-5":   defaultTimeout,
		"30":   30 * time.Second,
		"9000": maxTimeout,
	}
	for in, want := range cases {
		if got := parseTimeout(in); got != want {
			t.Errorf("parseTimeout(%q) = %v, want %v", in, got, want)
		}
	}
}

func TestCountURLs(t *testing.T) {
	cases := map[string]int{
		"":                          0,
		"   ":                       0,
		"ntfy://topic":              1,
		"mailto://a@b, ntfy://t":    2,
		"a://one b://two c://three": 3,
	}
	for in, want := range cases {
		if got := countURLs(in); got != want {
			t.Errorf("countURLs(%q) = %d, want %d", in, got, want)
		}
	}
}

// ---- test server -------------------------------------------------------------

// call records one request the fake Apprise server received.
type call struct {
	method  string
	path    string
	query   string
	accept  string
	ctype   string
	user    string
	pass    string
	hasAuth bool
	payload map[string]any
}

// fakeApprise is an apprise-api stand-in: it records what it was asked and
// answers from the handler the test installs.
type fakeApprise struct {
	t       *testing.T
	srv     *httptest.Server
	calls   []call
	handler func(w http.ResponseWriter, r *http.Request, c call)
}

func newFake(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, c call)) *fakeApprise {
	t.Helper()
	f := &fakeApprise{t: t, handler: handler}
	f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c := call{
			method: r.Method, path: r.URL.Path, query: r.URL.RawQuery,
			accept: r.Header.Get("Accept"), ctype: r.Header.Get("Content-Type"),
		}
		c.user, c.pass, c.hasAuth = r.BasicAuth()
		if r.Body != nil {
			_ = json.NewDecoder(r.Body).Decode(&c.payload)
		}
		f.calls = append(f.calls, c)
		f.handler(w, r, c)
	}))
	t.Cleanup(f.srv.Close)
	return f
}

func (f *fakeApprise) last() call {
	f.t.Helper()
	if len(f.calls) == 0 {
		f.t.Fatal("the plugin made no request")
	}
	return f.calls[len(f.calls)-1]
}

// writeJSON answers with a status and body, the way apprise-api does for a JSON
// request.
func writeJSON(w http.ResponseWriter, status int, body string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, _ = w.Write([]byte(body))
}

// statusOK is the /status body of a healthy default apprise-api deployment.
const statusOK = `{"config_lock":false,"attach_lock":false,"stateful_enabled":true,
  "max_attachments":6,"attach_size":200,
  "status":{"persistent_storage":true,"can_write_config":true,"can_write_attach":true,"details":["OK"]}}`

// newPlugin builds a plugin pointed at the fake server.
func newPlugin(t *testing.T, f *fakeApprise, cfg map[string]string) *Plugin {
	t.Helper()
	if cfg == nil {
		cfg = map[string]string{}
	}
	cfg["baseUrl"] = f.srv.URL
	p := &Plugin{}
	if err := p.Init(context.Background(), cfg); err != nil {
		t.Fatalf("Init: %v", err)
	}
	return p
}

func invoke(t *testing.T, p *Plugin, action string, params string, into any) error {
	t.Helper()
	var raw json.RawMessage
	if params != "" {
		raw = json.RawMessage(params)
	}
	out, err := p.Invoke(context.Background(), action, raw)
	if err != nil {
		return err
	}
	if into != nil {
		if err := json.Unmarshal(out, into); err != nil {
			t.Fatalf("decoding the %s result: %v", action, err)
		}
	}
	return nil
}

// ---- notify ------------------------------------------------------------------

func TestNotifyStateful(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, `{"error":null,"details":[["INFO","2026-08-30 10:00:00,000","Sent Telegram notification."]]}`)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "tag": "ops", "format": "markdown"})

	var res NotifyResult
	if err := invoke(t, p, "notify", `{"body":"Charge complete","title":"Kia EV6","type":"success"}`, &res); err != nil {
		t.Fatalf("notify: %v", err)
	}

	c := f.last()
	if c.method != http.MethodPost || c.path != "/notify/drivervault" {
		t.Fatalf("request = %s %s, want POST /notify/drivervault", c.method, c.path)
	}
	// Both headers are what make apprise-api answer in JSON instead of HTML.
	if !strings.Contains(c.accept, "application/json") || !strings.Contains(c.ctype, "application/json") {
		t.Errorf("Accept = %q, Content-Type = %q; both should be application/json", c.accept, c.ctype)
	}
	if c.hasAuth {
		t.Error("no Basic auth is configured, so none should be sent")
	}
	want := map[string]any{
		"body": "Charge complete", "title": "Kia EV6",
		"type": "success", "format": "markdown", "tag": "ops",
	}
	for k, v := range want {
		if c.payload[k] != v {
			t.Errorf("payload[%q] = %v, want %v", k, c.payload[k], v)
		}
	}
	if _, sent := c.payload["urls"]; sent {
		t.Error("a stateful call must not carry URLs")
	}

	if !res.OK || res.Mode != "stateful" || res.Key != "drivervault" || res.Tag != "ops" {
		t.Fatalf("result = %+v", res)
	}
	if len(res.Log) != 1 || res.Log[0].Level != "INFO" || !strings.Contains(res.Log[0].Message, "Telegram") {
		t.Errorf("delivery log = %+v", res.Log)
	}
	if res.SentAt.IsZero() {
		t.Error("a delivered notification should be stamped")
	}
}

func TestNotifyStatelessUsesConfiguredURLs(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
	})
	p := newPlugin(t, f, map[string]string{"urls": "ntfy://topic, mailto://user:pass@host"})

	var res NotifyResult
	if err := invoke(t, p, "notify", `{"body":"Service due"}`, &res); err != nil {
		t.Fatalf("notify: %v", err)
	}

	c := f.last()
	if c.path != "/notify" {
		t.Fatalf("path = %q, want /notify (the stateless endpoint)", c.path)
	}
	if c.payload["urls"] != "ntfy://topic, mailto://user:pass@host" {
		t.Errorf("urls = %v, want the configured list", c.payload["urls"])
	}
	if _, sent := c.payload["tag"]; sent {
		t.Error("a tag means nothing to a stateless call and should not be sent")
	}
	// Defaults fill in for what the caller left out.
	if c.payload["type"] != typeInfo || c.payload["format"] != formatText {
		t.Errorf("defaults = type %v / format %v, want info / text", c.payload["type"], c.payload["format"])
	}
	if res.Mode != "stateless" || res.Targets != 2 {
		t.Errorf("result = %+v, want stateless with 2 targets", res)
	}
	if res.Key != "" {
		t.Errorf("a stateless result should name no key, got %q", res.Key)
	}
}

// A call that names its own URLs must not be sent to the configured key as well
// — the caller chose the destination.
func TestNotifyCallerURLsOverrideConfiguredKey(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})

	var res NotifyResult
	if err := invoke(t, p, "notify", `{"body":"one-off","urls":"ntfy://alerts"}`, &res); err != nil {
		t.Fatalf("notify: %v", err)
	}
	if c := f.last(); c.path != "/notify" || c.payload["urls"] != "ntfy://alerts" {
		t.Fatalf("request = %s with urls %v, want the stateless endpoint with the caller's URL", c.path, c.payload["urls"])
	}
	if res.Mode != "stateless" {
		t.Errorf("mode = %q, want stateless", res.Mode)
	}
}

func TestNotifyKeyParamOverridesConfiguredKey(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault", "urls": "ntfy://ignored"})

	if err := invoke(t, p, "notify", `{"body":"hi","key":"fleet","tag":"night"}`, nil); err != nil {
		t.Fatalf("notify: %v", err)
	}
	c := f.last()
	if c.path != "/notify/fleet" {
		t.Fatalf("path = %q, want /notify/fleet", c.path)
	}
	if c.payload["tag"] != "night" {
		t.Errorf("tag = %v, want the per-call night", c.payload["tag"])
	}
	if _, sent := c.payload["urls"]; sent {
		t.Error("the configured URLs must not ride along with a keyed call")
	}
}

func TestNotifyAttachAcceptsStringOrList(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, `{"error":null,"details":[]}`)
	})
	p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})

	if err := invoke(t, p, "notify", `{"body":"b","attach":"https://example.com/a.png"}`, nil); err != nil {
		t.Fatalf("notify with one attachment: %v", err)
	}
	if got := f.last().payload["attach"]; len(got.([]any)) != 1 {
		t.Errorf("attach = %v, want a one-element list", got)
	}

	if err := invoke(t, p, "notify", `{"body":"b","attach":["https://example.com/a.png","https://example.com/b.pdf"]}`, nil); err != nil {
		t.Fatalf("notify with two attachments: %v", err)
	}
	if got := f.last().payload["attach"]; len(got.([]any)) != 2 {
		t.Errorf("attach = %v, want a two-element list", got)
	}

	// An empty string is no attachment at all, not an empty one.
	if err := invoke(t, p, "notify", `{"body":"b","attach":""}`, nil); err != nil {
		t.Fatalf("notify with an empty attachment: %v", err)
	}
	if _, sent := f.last().payload["attach"]; sent {
		t.Error("an empty attach should not be sent")
	}
}

func TestNotifyValidation(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, `{"error":null}`)
	})
	p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})

	cases := map[string]string{
		"empty body": `{"body":"   "}`,
		"bad type":   `{"body":"b","type":"urgent"}`,
		"bad key":    `{"body":"b","key":"has space"}`,
		"bad params": `{"body":`,
		"bad attach": `{"body":"b","attach":{"url":"x"}}`,
	}
	for name, params := range cases {
		if err := invoke(t, p, "notify", params, nil); err == nil {
			t.Errorf("%s should have been rejected", name)
		}
	}
	if len(f.calls) != 0 {
		t.Errorf("nothing invalid should reach the server, got %d call(s)", len(f.calls))
	}

	// An unrecognised format is not worth failing a notification over: Apprise
	// converts formats anyway, so it falls back to the configured one.
	if err := invoke(t, p, "notify", `{"body":"b","format":"yaml"}`, nil); err != nil {
		t.Fatalf("an odd format should fall back, not fail: %v", err)
	}
	if got := f.last().payload["format"]; got != formatText {
		t.Errorf("format = %v, want the configured %q", got, formatText)
	}
}

func TestNotifyReportsDeliveryFailure(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, statusFailedDependency, `{"error":"One or more notification could not be sent",
			"details":[["WARNING","2026-08-30 10:00:00,000","Failed to send Discord notification: 401"]]}`)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})

	err := invoke(t, p, "notify", `{"body":"b"}`, nil)
	if err == nil {
		t.Fatal("a 424 means nothing was delivered and must surface as an error")
	}
	// The log line is the only place Apprise says which service refused.
	if !strings.Contains(err.Error(), "Discord") || !strings.Contains(err.Error(), "401") {
		t.Errorf("error should carry the delivery log, got: %v", err)
	}
}

func TestNotifyUnknownKeyIsExplained(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		w.WriteHeader(http.StatusNoContent)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "missing"})

	err := invoke(t, p, "notify", `{"body":"b"}`, nil)
	if err == nil {
		t.Fatal("a 204 means the key holds nothing and must not read as success")
	}
	if !strings.Contains(err.Error(), "missing") {
		t.Errorf("error should name the key, got: %v", err)
	}
}

func TestBasicAuthIsSentWhenConfigured(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, `{"error":null}`)
	})
	p := newPlugin(t, f, map[string]string{"urls": "ntfy://t", "username": "ops", "password": "s3cret"})

	if err := invoke(t, p, "notify", `{"body":"b"}`, nil); err != nil {
		t.Fatalf("notify: %v", err)
	}
	c := f.last()
	if !c.hasAuth || c.user != "ops" || c.pass != "s3cret" {
		t.Errorf("Basic auth = %q/%q (present %v), want ops/s3cret", c.user, c.pass, c.hasAuth)
	}
}

// ---- targets -----------------------------------------------------------------

const targetsBody = `{"tags":["ops","night"],"urls":[
  {"id":"a1","service_name":"Telegram","enabled":true,"url":"tgram://****/1234","tags":["ops"]},
  {"id":"b2","service_name":"E-Mail","enabled":true,"url":"mailto://user:****@host","tags":["ops","night"]}]}`

func TestTargetsForcesPrivacy(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, targetsBody)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})

	var res TargetsResult
	if err := invoke(t, p, "targets", "", &res); err != nil {
		t.Fatalf("targets: %v", err)
	}

	c := f.last()
	if c.path != "/json/urls/drivervault" {
		t.Fatalf("path = %q, want /json/urls/drivervault", c.path)
	}
	// privacy=1 is what keeps downstream tokens on the Apprise side.
	if !strings.Contains(c.query, "privacy=1") {
		t.Errorf("query = %q, want privacy=1", c.query)
	}
	if !strings.Contains(c.query, "tag="+tagAll) {
		t.Errorf("query = %q, want the default tag=%s", c.query, tagAll)
	}
	if res.Count != 2 || len(res.Targets) != 2 {
		t.Fatalf("count = %d, want 2", res.Count)
	}
	if res.Targets[0].ServiceName != "Telegram" || res.Targets[0].ID != "a1" {
		t.Errorf("first target = %+v", res.Targets[0])
	}
	if len(res.Tags) != 2 {
		t.Errorf("tags = %v, want both", res.Tags)
	}
}

func TestTargetsEmptyKeyIsNotAFailure(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		w.WriteHeader(http.StatusNoContent)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "fresh"})

	var res TargetsResult
	if err := invoke(t, p, "targets", "", &res); err != nil {
		t.Fatalf("a key with no configuration is an empty list, not an error: %v", err)
	}
	if res.Count != 0 || res.Targets == nil {
		t.Errorf("result = %+v, want an empty (non-null) target list", res)
	}
}

func TestTargetsNeedsAKey(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, targetsBody)
	})
	p := newPlugin(t, f, map[string]string{"urls": "ntfy://t"})

	if err := invoke(t, p, "targets", "", nil); err == nil {
		t.Fatal("targets without a key anywhere should be refused")
	}
	if len(f.calls) != 0 {
		t.Error("the refusal should not reach the server")
	}

	// A key given per call is enough.
	if err := invoke(t, p, "targets", `{"key":"fleet","tag":"night"}`, nil); err != nil {
		t.Fatalf("targets with a per-call key: %v", err)
	}
	if c := f.last(); c.path != "/json/urls/fleet" || !strings.Contains(c.query, "tag=night") {
		t.Errorf("request = %s?%s, want the per-call key and tag", c.path, c.query)
	}
}

// ---- services ----------------------------------------------------------------

const detailsBody = `{"version":"1.9.4","asset":{},"schemas":[
  {"service_name":"Telegram","service_url":"https://telegram.org/","setup_url":"https://github.com/caronc/apprise/wiki/Notify_telegram",
   "category":"native","attachment_support":true,"protocols":null,"secure_protocols":["tgram"],
   "details":{"templates":["{schema}://{bot_token}"]}},
  {"service_name":"Ntfy","service_url":"https://ntfy.sh/","setup_url":"",
   "category":"native","attachment_support":true,"protocols":["ntfy"],"secure_protocols":["ntfys"],
   "details":{"templates":["{schema}://{topic}"]}}]}`

func TestServices(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, detailsBody)
	})
	p := newPlugin(t, f, nil)

	var res ServicesResult
	if err := invoke(t, p, "services", "", &res); err != nil {
		t.Fatalf("services: %v", err)
	}
	if c := f.last(); c.path != "/details" || c.query != "" {
		t.Fatalf("request = %s?%s, want /details with no query", c.path, c.query)
	}
	if res.Version != "1.9.4" || res.Count != 2 {
		t.Fatalf("result = version %q, count %d", res.Version, res.Count)
	}
	tg := res.Services[0]
	if tg.Name != "Telegram" || !tg.AttachmentSupport || len(tg.SecureProtocols) != 1 {
		t.Errorf("Telegram = %+v", tg)
	}
	if tg.Protocols != nil {
		t.Errorf("a null protocol list should stay empty, got %v", tg.Protocols)
	}
	if tg.Enabled != nil {
		t.Error("enabled is only reported when disabled services were asked for")
	}

	// The catalogue's per-service templates are dropped: they are hundreds of KiB
	// and nothing here consumes them.
	out, _ := json.Marshal(res)
	if strings.Contains(string(out), "templates") {
		t.Error("the raw service templates should not be passed through")
	}

	if err := invoke(t, p, "services", `{"all":true}`, nil); err != nil {
		t.Fatalf("services all: %v", err)
	}
	if c := f.last(); !strings.Contains(c.query, "all=yes") {
		t.Errorf("query = %q, want all=yes", c.query)
	}
}

// ---- status ------------------------------------------------------------------

func TestStatusJSON(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, statusOK)
	})
	p := newPlugin(t, f, nil)

	var st Status
	if err := invoke(t, p, "status", "", &st); err != nil {
		t.Fatalf("status: %v", err)
	}
	if !st.OK || !st.StatefulEnabled || !st.CanWriteConfig || st.MaxAttachments != 6 {
		t.Fatalf("status = %+v", st)
	}
	if len(st.Details) != 1 || st.Details[0] != "OK" {
		t.Errorf("details = %v, want [OK]", st.Details)
	}
}

// Apprise answers 417 — not 500 — when it finds a problem with itself, and the
// body still says which. That is an answer, not a transport failure.
func TestStatusProblemIsStillParsed(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, statusExpectationFail, `{"config_lock":false,"attach_lock":false,"stateful_enabled":true,
			"max_attachments":6,"attach_size":200,
			"status":{"persistent_storage":false,"can_write_config":false,"can_write_attach":true,
			"details":["CONFIG_PERMISSION_ISSUE"]}}`)
	})
	p := newPlugin(t, f, nil)

	var st Status
	if err := invoke(t, p, "status", "", &st); err != nil {
		t.Fatalf("a 417 should still parse: %v", err)
	}
	if st.OK {
		t.Error("a permission issue is not OK")
	}
	if len(st.Details) != 1 || st.Details[0] != "CONFIG_PERMISSION_ISSUE" {
		t.Errorf("details = %v", st.Details)
	}
}

// A proxy that strips the Accept header, or an older release, answers /status in
// plain text with the same codes.
func TestStatusPlainTextFallback(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		w.Header().Set("Content-Type", "text/plain")
		_, _ = w.Write([]byte("OK"))
	})
	p := newPlugin(t, f, nil)

	var st Status
	if err := invoke(t, p, "status", "", &st); err != nil {
		t.Fatalf("plain-text status should be read, not rejected: %v", err)
	}
	if !st.OK || len(st.Details) != 1 || st.Details[0] != "OK" {
		t.Errorf("status = %+v", st)
	}
}

// An HTML error page from something that is not Apprise must not be mistaken for
// a health line.
func TestStatusRejectsNonAppriseBody(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		w.Header().Set("Content-Type", "text/html")
		_, _ = w.Write([]byte("<html><body>Not Apprise</body></html>"))
	})
	p := newPlugin(t, f, nil)

	if err := invoke(t, p, "status", "", nil); err == nil {
		t.Fatal("an HTML page should not pass as a status")
	}
}

// ---- health ------------------------------------------------------------------

func TestHealthCheckOKWithConfigKey(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		if strings.HasPrefix(c.path, "/json/urls/") {
			writeJSON(w, http.StatusOK, targetsBody)
			return
		}
		writeJSON(w, http.StatusOK, statusOK)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})

	h := p.HealthCheck(context.Background())
	if h.Status != plugins.StatusOK {
		t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail)
	}
	if !strings.Contains(h.Detail, "2 target") {
		t.Errorf("detail should count the targets, got %q", h.Detail)
	}
}

// A reachable server whose config holds nothing to notify is degraded, not down:
// the half we address works, and the missing half is the operator's config.
func TestHealthCheckDegradedWhenKeyIsEmpty(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		if strings.HasPrefix(c.path, "/json/urls/") {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		writeJSON(w, http.StatusOK, statusOK)
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})

	h := p.HealthCheck(context.Background())
	if h.Status != plugins.StatusDegraded {
		t.Fatalf("status = %q, want degraded", h.Status)
	}
	if !strings.Contains(h.Detail, "no notification URLs") {
		t.Errorf("detail = %q", h.Detail)
	}
}

func TestHealthCheckDegradedWithNoDestination(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, statusOK)
	})
	p := newPlugin(t, f, nil)

	h := p.HealthCheck(context.Background())
	if h.Status != plugins.StatusDegraded {
		t.Fatalf("status = %q, want degraded", h.Status)
	}
	if !strings.Contains(h.Detail, "nothing to notify") {
		t.Errorf("detail = %q", h.Detail)
	}
	if len(f.calls) != 1 {
		t.Errorf("with nothing configured there is no key to read, so one call is enough; got %d", len(f.calls))
	}
}

// A config key against a server that has stateful mode switched off can never
// resolve, and saying so is more useful than an empty target list.
func TestHealthCheckDegradedWhenStatefulDisabled(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, strings.Replace(statusOK, `"stateful_enabled":true`, `"stateful_enabled":false`, 1))
	})
	p := newPlugin(t, f, map[string]string{"configKey": "drivervault"})

	h := p.HealthCheck(context.Background())
	if h.Status != plugins.StatusDegraded {
		t.Fatalf("status = %q, want degraded", h.Status)
	}
	if !strings.Contains(h.Detail, "stateful config disabled") {
		t.Errorf("detail = %q", h.Detail)
	}
}

func TestHealthCheckOKStateless(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {
		writeJSON(w, http.StatusOK, statusOK)
	})
	p := newPlugin(t, f, map[string]string{"urls": "ntfy://a, ntfy://b, ntfy://c"})

	h := p.HealthCheck(context.Background())
	if h.Status != plugins.StatusOK {
		t.Fatalf("status = %q (%s), want ok", h.Status, h.Detail)
	}
	if !strings.Contains(h.Detail, "3 configured URL") {
		t.Errorf("detail = %q", h.Detail)
	}
}

func TestHealthCheckDownWhenUnreachable(t *testing.T) {
	f := newFake(t, func(w http.ResponseWriter, r *http.Request, c call) {})
	p := newPlugin(t, f, nil)
	f.srv.Close() // the address stops answering

	h := p.HealthCheck(context.Background())
	if h.Status != plugins.StatusDown {
		t.Fatalf("status = %q, want down", h.Status)
	}
}

func TestHealthCheckWithoutBaseURLIsDown(t *testing.T) {
	p := &Plugin{}
	if err := p.Init(context.Background(), map[string]string{}); err != nil {
		t.Fatalf("Init with no address should not fail, it should read as unhealthy: %v", err)
	}
	h := p.HealthCheck(context.Background())
	if h.Status != plugins.StatusDown {
		t.Fatalf("status = %q, want down", h.Status)
	}
	if !strings.Contains(h.Detail, "base URL") {
		t.Errorf("detail should say what is missing, got %q", h.Detail)
	}
}

// ---- contract ----------------------------------------------------------------

func TestUnknownAction(t *testing.T) {
	p := &Plugin{}
	if _, err := p.Invoke(context.Background(), "explode", nil); err == nil {
		t.Fatal("an unknown action should be refused")
	}
}

func TestShutdownIsSafe(t *testing.T) {
	if err := (&Plugin{}).Shutdown(context.Background()); err != nil {
		t.Fatalf("Shutdown: %v", err)
	}
}

// The manager builds an instance and may never reach Init before a probe; that
// must not panic on a nil client.
func TestUninitializedPluginDoesNotPanic(t *testing.T) {
	p := &Plugin{}
	if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
		t.Fatalf("status = %q, want down", h.Status)
	}
}

func TestRegisteredWithTheRegistry(t *testing.T) {
	// Register panics on a duplicate, so a second registration under the same
	// name would already have failed at init. This pins the name itself.
	if got := (&Plugin{}).Descriptor().Name; got != "apprise" {
		t.Fatalf("registered name = %q", got)
	}
}
