package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/kami/maven/internal/email"
	"github.com/kami/maven/internal/ipc"
)

// ---- a fake mailbox -------------------------------------------------------
//
// It fakes the READ, not the protocol: internal/email owns the IMAP tests, and
// its dialer is unexported precisely so this package cannot substitute a
// transport.

type fakeIMAP struct {
	msgs map[uint32]string
	uids []uint32
	cmds []string
}

// fetch is the read seam the reader exposes: the daemon cannot reach
// internal/email's dialer (it is unexported so nothing outside that package can
// point the reader at a cleartext transport), so a test fakes the whole read.
// The IMAP protocol itself is covered by internal/email's own tests.
func (f *fakeIMAP) fetch(r *reader) func(string) ([]email.Message, error) {
	return func(string) ([]email.Message, error) {
		var out []email.Message
		var low uint32
		for _, uid := range f.uids {
			if low == 0 || uid < low {
				low = uid
			}
		}
		if low > 0 {
			r.state.retire(low)
		}
		for i := len(f.uids) - 1; i >= 0; i-- {
			uid := f.uids[i]
			if r.state.seen(uid) {
				continue
			}
			raw, ok := f.msgs[uid]
			if !ok {
				continue
			}
			f.cmds = append(f.cmds, fmt.Sprintf("UID FETCH %d", uid))
			msg, err := email.ParseMessage(uid, []byte(raw))
			if err != nil {
				continue
			}
			out = append(out, msg)
			if r.max > 0 && len(out) >= r.max {
				break
			}
		}
		return out, nil
	}
}

// ---- a fake core -----------------------------------------------------------

type fakeCore struct {
	got  []ipc.IngestMailReq
	resp ipc.IngestMailResp
	err  error
}

func (c *fakeCore) IngestMail(_ context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
	c.got = append(c.got, req)
	if c.err != nil {
		return ipc.IngestMailResp{}, c.err
	}
	return c.resp, nil
}

func mail(subject, body string, extraHeaders ...string) string {
	h := "Subject: " + subject + "\r\nFrom: a@b.c\r\nContent-Type: text/plain; charset=utf-8\r\n"
	for _, e := range extraHeaders {
		h += e + "\r\n"
	}
	return h + "\r\n" + body + "\r\n"
}

func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader {
	t.Helper()
	r := &reader{
		core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
		lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
		state: newSeenState(statePath),
	}
	r.fetchMail = f.fetch(r)
	return r
}

func TestPollHandsMessagesToCore(t *testing.T) {
	f := &fakeIMAP{
		uids: []uint32{1, 2},
		msgs: map[uint32]string{
			1: mail("Счёт", "Оплатить до 5 августа."),
			2: mail("Скидки", "Sale!", "List-Unsubscribe: <mailto:u@x>"),
		},
	}
	core := &fakeCore{resp: ipc.IngestMailResp{TaskIDs: []int64{1}, Created: 1}}
	r := newTestReader(t, f, core, "")
	r.pollOnce(context.Background(), "secret")

	// Two calls: the real mail with its text, and the newsletter as a verdict
	// with no content at all. Core is told about bulk rather than asked, so it
	// can count it without spending the model.
	if len(core.got) != 2 {
		t.Fatalf("core saw %d messages, want 2: %+v", len(core.got), core.got)
	}
	var got, bulk ipc.IngestMailReq
	for _, r := range core.got {
		if r.Junk {
			bulk = r
		} else {
			got = r
		}
	}
	if bulk.UID != 2 || !bulk.Junk {
		t.Errorf("bulk req = %+v, want uid 2 flagged junk", bulk)
	}
	if bulk.Subject != "" || bulk.Body != "" || bulk.From != "" {
		t.Errorf("a bulk verdict must carry no mail content: %+v", bulk)
	}
	if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" {
		t.Errorf("ingest req = %+v", got)
	}
	if !strings.Contains(got.Body, "Оплатить") {
		t.Errorf("body = %q", got.Body)
	}
}

// A second poll must not re-send what core already saw — extraction is a
// multi-second LLM call per message.
func TestPollSkipsSeenUIDs(t *testing.T) {
	f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
	core := &fakeCore{}
	r := newTestReader(t, f, core, "")
	r.pollOnce(context.Background(), "secret")
	r.pollOnce(context.Background(), "secret")
	if len(core.got) != 1 {
		t.Errorf("core saw %d messages over two polls, want 1", len(core.got))
	}
}

// An ingest that failed is NOT marked seen: the next poll retries it.
func TestPollRetriesFailedIngest(t *testing.T) {
	f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
	core := &fakeCore{err: fmt.Errorf("llama-server is warming up")}
	r := newTestReader(t, f, core, "")
	r.pollOnce(context.Background(), "secret")
	core.err = nil
	r.pollOnce(context.Background(), "secret")
	if len(core.got) != 2 {
		t.Errorf("core saw %d attempts, want 2 (a failed ingest is retried)", len(core.got))
	}
}

// Core without an email block ⇒ stop, don't hammer the socket.
func TestPollStopsWhenCoreRefusesMail(t *testing.T) {
	f := &fakeIMAP{uids: []uint32{1, 2}, msgs: map[uint32]string{1: mail("a", "b"), 2: mail("c", "d")}}
	core := &fakeCore{err: fmt.Errorf("call: %w", ipc.ErrUnknownMethod)}
	r := newTestReader(t, f, core, "")
	r.pollOnce(context.Background(), "secret")
	if !r.disabled {
		t.Error("ErrUnknownMethod must disable the reader")
	}
	if len(core.got) != 1 {
		t.Errorf("core saw %d messages, want 1 — stop at the first refusal", len(core.got))
	}
}

func TestSeenStatePersists(t *testing.T) {
	path := filepath.Join(t.TempDir(), "state", "seen.json")
	f := &fakeIMAP{uids: []uint32{9}, msgs: map[uint32]string{9: mail("Счёт", "текст")}}
	core := &fakeCore{}
	r := newTestReader(t, f, core, path)
	r.pollOnce(context.Background(), "secret")

	fi, err := os.Stat(path)
	if err != nil {
		t.Fatalf("state file: %v", err)
	}
	// A list of message ids from his mailbox is metadata about his mail.
	if perm := fi.Mode().Perm(); perm != 0o600 {
		t.Errorf("state file mode = %v, want 0600", perm)
	}

	// A fresh reader with the same state file must not re-read the message.
	core2 := &fakeCore{}
	r2 := newTestReader(t, f, core2, path)
	if err := r2.state.load(); err != nil {
		t.Fatalf("load: %v", err)
	}
	r2.pollOnce(context.Background(), "secret")
	if len(core2.got) != 0 {
		t.Errorf("after a restart core saw %d messages, want 0", len(core2.got))
	}
}

func TestSeenStateHighWaterMark(t *testing.T) {
	s := newSeenState("")
	s.mark(1)
	s.mark(3)
	s.mark(2)
	if s.high != 3 {
		t.Errorf("high = %d, want 3 (contiguous run collapses)", s.high)
	}
	if len(s.set) != 0 {
		t.Errorf("explicit set = %v, want empty", s.set)
	}
	if !s.seen(2) || s.seen(4) {
		t.Errorf("seen(2)=%v seen(4)=%v", s.seen(2), s.seen(4))
	}
}

func TestSeenStateCorruptFileIsNotFatal(t *testing.T) {
	path := filepath.Join(t.TempDir(), "seen.json")
	if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
		t.Fatal(err)
	}
	s := newSeenState(path)
	if err := s.load(); err == nil {
		t.Error("a corrupt state file should report an error the caller logs")
	}
	if s.seen(1) {
		t.Error("a corrupt state file must leave an empty seen-set, not a poisoned one")
	}
}

// Off unless configured, and the credential is never a flag value.
func TestRunRequiresConfig(t *testing.T) {
	if err := run([]string{}); err == nil {
		t.Error("no -socket must be an error")
	}
	if err := run([]string{"-socket", "/tmp/nope.sock"}); err == nil {
		t.Error("no mailbox configuration must be an error, not a default mailbox")
	}
	// There is no -password flag at all: only -password-file.
	if err := run([]string{"-socket", "/x", "-imap", "h", "-user", "u", "-password", "p"}); err == nil ||
		!strings.Contains(err.Error(), "flag provided but not defined") {
		t.Errorf("a -password flag must not exist; err = %v", err)
	}
}

func TestRunRejectsEmptyPasswordFile(t *testing.T) {
	path := filepath.Join(t.TempDir(), "pass")
	if err := os.WriteFile(path, []byte("  \n"), 0o600); err != nil {
		t.Fatal(err)
	}
	err := run([]string{"-socket", "/x/y.sock", "-imap", "h", "-user", "u", "-password-file", path})
	if err == nil || !strings.Contains(err.Error(), "empty") {
		t.Errorf("an empty password file must be refused before dialling; err = %v", err)
	}
}

// A UID that never ingests pinned the high-water mark forever, because the mark
// only advances through a contiguous run. Once that UID falls out of the
// lookback window it can never be fetched again, so there is nothing left to
// wait for and everything above it can leave the explicit set.
func TestSeenStateRetiresAgedOutUIDs(t *testing.T) {
	s := newSeenState("")
	s.mark(1000) // 999 failed and was deliberately not marked
	s.mark(1001)
	if s.high != 0 || len(s.set) != 2 {
		t.Fatalf("high = %d, set = %v; want the mark pinned below the gap", s.high, s.set)
	}
	// The next SEARCH window starts at 1000: 999 has aged out.
	s.retire(1000)
	if s.high != 1001 {
		t.Errorf("high = %d, want 1001 once the gap is unreachable", s.high)
	}
	if len(s.set) != 0 {
		t.Errorf("explicit set = %v, want empty", s.set)
	}
	if !s.seen(999) || !s.seen(1001) || s.seen(1002) {
		t.Errorf("seen(999)=%v seen(1001)=%v seen(1002)=%v", s.seen(999), s.seen(1001), s.seen(1002))
	}
}

// retire never moves the mark backwards: a UIDVALIDITY reset restarts UIDs low,
// and that must not un-see a mailbox or re-see one.
func TestSeenStateRetireNeverGoesBackwards(t *testing.T) {
	s := newSeenState("")
	s.mark(1)
	s.mark(2)
	s.retire(1)
	if s.high != 2 {
		t.Errorf("high = %d, want 2 unchanged", s.high)
	}
}

// A poll must not leave the state file growing with UIDs that are already
// covered by the high-water mark.
func TestPollRetiresThroughTheSearchWindow(t *testing.T) {
	f := &fakeIMAP{uids: []uint32{100, 101}, msgs: map[uint32]string{100: mail("a", "b"), 101: mail("c", "d")}}
	core := &fakeCore{}
	r := newTestReader(t, f, core, "")
	r.pollOnce(context.Background(), "secret")
	if r.state.high != 101 {
		t.Errorf("high = %d, want 101 — everything below the search window is unreachable", r.state.high)
	}
	if len(r.state.set) != 0 {
		t.Errorf("explicit set = %v, want empty", r.state.set)
	}
}
