package main

import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"testing"
	"time"

	"github.com/kami/maven/internal/config"
	"github.com/kami/maven/internal/event"
	"github.com/kami/maven/internal/ipc"
)

var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)

func intakeClock() time.Time { return intakeNow }

// failingAPI wraps the store adapter, failing the three intake writes on
// demand, so the "a failed write publishes nothing" invariant is testable.
type failingAPI struct {
	ipc.CoreAPI
	fail bool
}

func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
	if f.fail {
		return 0, errors.New("injected")
	}
	return f.CoreAPI.WriteFact(ctx, req)
}

func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
	t.Helper()
	st := newTestStore(t)
	bus := event.NewBus(32)
	return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
}

func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
	// The adoption invariant: with the journal off there is not even a
	// decorator on the intake path, so production behaves exactly as before.
	st := newTestStore(t)
	bare := ipc.NewStoreAPI(st)
	if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
		t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
	}
}

func TestNewEventBusOffWhenNegative(t *testing.T) {
	if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
		t.Error("intake_journal = -1 still built a bus")
	}
	if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
		t.Error("intake_journal = 4 built no bus")
	}
}

func TestIntakeJournalsAFactWrite(t *testing.T) {
	api, bus := newIntakeTestAPI(t)
	ctx := context.Background()
	// The ambient path's shape: an env fact below full confidence, timestamped
	// at the meeting's start rather than at notice time.
	start := intakeNow.Add(2 * time.Hour)
	if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
		Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
		Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
	}); err != nil {
		t.Fatalf("WriteFact: %v", err)
	}
	got := bus.Recent(0)
	if len(got) != 1 {
		t.Fatalf("journal has %d entries, want 1", len(got))
	}
	e := got[0]
	if e.Source != "ambient:notif" || e.Kind != event.KindFact {
		t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
	}
	if e.Title != "calendar_event_20260801_планёрка" {
		t.Errorf("title = %q, want the fact key", e.Title)
	}
	if !e.OccurredAt.Equal(start) {
		t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
	}
	if e.Priority != event.PriorityLow {
		t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
	}
}

func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
	st := newTestStore(t)
	bus := event.NewBus(8)
	api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
	if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
		Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
	}); err == nil {
		t.Fatal("expected the injected error")
	}
	if bus.Len() != 0 {
		t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
	}
}

func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
	api, bus := newIntakeTestAPI(t)
	// The RSS shape: "headline\nsummary\nlink".
	if _, err := api.WriteNote(context.Background(), intakeNow,
		"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
		t.Fatalf("WriteNote: %v", err)
	}
	got := bus.Recent(1)
	if len(got) != 1 {
		t.Fatalf("journal has %d entries, want 1", len(got))
	}
	if got[0].Title != "Вышло ядро 6.19" {
		t.Errorf("title = %q, want the headline", got[0].Title)
	}
	if got[0].Kind != event.KindNote {
		t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
	}
	if got[0].Body == "" {
		t.Error("body is empty, want the rest of the note")
	}
}

func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
	api, bus := newIntakeTestAPI(t)
	ctx := context.Background()
	req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
	if _, err := api.CaptureTask(ctx, req); err != nil {
		t.Fatalf("CaptureTask: %v", err)
	}
	// Same text again: CaptureTask dedupes among live rows, and a re-read of a
	// mailbox must not refill the journal.
	resp, err := api.CaptureTask(ctx, req)
	if err != nil {
		t.Fatalf("CaptureTask (repeat): %v", err)
	}
	if resp.Created {
		t.Fatal("store did not dedupe; the test cannot check what it means to")
	}
	if bus.Len() != 1 {
		t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
	}
	if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
		t.Errorf("entry = %+v, want the captured task", got)
	}
}

func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
	api, bus := newIntakeTestAPI(t)
	ctx := context.Background()
	for _, key := range []string{"a", "b", "c"} {
		if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
			Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
		}); err != nil {
			t.Fatalf("WriteFact %s: %v", key, err)
		}
	}
	fn := intakeEventsFn(bus)
	got := fn(2)
	if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
		t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
	}
	if intakeEventsFn(nil) != nil {
		t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
	}
}

func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
	d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
	got, err := d.RecentEvents(context.Background(), 10)
	if err != nil {
		t.Fatalf("RecentEvents with no journal errored: %v", err)
	}
	if len(got) != 0 {
		t.Errorf("got %d events, want none", len(got))
	}
}

// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash,
// the praxis trace of an act she performed and the quiet-hours toggle he
// pressed all landed on a page headed "everything that arrived", and on a cold
// start a handful of feeds could evict real intake behind their marks.
func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) {
	api, bus := newIntakeTestAPI(t)
	ctx := context.Background()
	for _, req := range []ipc.WriteFactReq{
		{Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0},
		{Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0},
		{Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0},
		{Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0},
	} {
		if _, err := api.WriteFact(ctx, req); err != nil {
			t.Fatalf("WriteFact(%s): %v", req.Key, err)
		}
	}
	if n := bus.Len(); n != 0 {
		t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0))
	}
	// A real arrival under the same decorator still lands.
	if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
		Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200",
		Source: "poll:zenmoney", Confidence: 1.0,
	}); err != nil {
		t.Fatal(err)
	}
	if bus.Len() != 1 {
		t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0))
	}
}

// Confidence is the distinction between an inference and a credentialled read,
// and the three-value priority bucket cannot carry it: unset and 1.0 land in
// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it.
func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) {
	api, bus := newIntakeTestAPI(t)
	ctx := context.Background()
	if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
		Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка",
		Source: "ambient:notif", Confidence: 0.6,
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
		Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0,
	}); err != nil {
		t.Fatal(err)
	}
	got := bus.Recent(0)
	if len(got) != 2 {
		t.Fatalf("journal has %d entries, want 2", len(got))
	}
	var relayed, stated factDetail
	if err := json.Unmarshal(got[1].Payload, &relayed); err != nil {
		t.Fatalf("payload: %v", err)
	}
	if relayed.Confidence == nil || *relayed.Confidence != 0.6 {
		t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence)
	}
	if relayed.FactKind != "env" {
		t.Errorf("fact_kind = %q, want env", relayed.FactKind)
	}
	// Both writes land in PriorityNormal or PriorityLow buckets that cannot be
	// told apart from the outside; the payload is where the two numbers stay
	// distinguishable.
	if err := json.Unmarshal(got[0].Payload, &stated); err != nil {
		t.Fatalf("payload: %v", err)
	}
	if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" {
		t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated)
	}
}

// A retraction is not an observation. It used to publish an envelope
// indistinguishable from a fresh reading of the same key.
func TestIntakeMarksARetraction(t *testing.T) {
	api, bus := newIntakeTestAPI(t)
	ctx := context.Background()
	id, err := api.WriteFact(ctx, ipc.WriteFactReq{
		Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
		Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web",
		Confidence: 1.0, VoidsID: &id,
	}); err != nil {
		t.Fatal(err)
	}
	e := bus.Recent(1)[0]
	if e.Priority != event.PriorityLow {
		t.Errorf("priority = %q, want low for a correction", e.Priority)
	}
	if !strings.HasPrefix(e.Title, "отмена:") {
		t.Errorf("title = %q, want it marked as a retraction", e.Title)
	}
	var d factDetail
	if err := json.Unmarshal(e.Payload, &d); err != nil {
		t.Fatal(err)
	}
	if d.VoidsID == nil || *d.VoidsID != id {
		t.Errorf("voids_id = %v, want %d", d.VoidsID, id)
	}
}
