package zenmoney

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

// fixtureServer replays testdata/diff.json and records the request, so the
// tests can assert the wire contract (Bearer token, POST, /v8/diff/) without a
// ZenMoney account.
func fixtureServer(t *testing.T, got *diffRequest, auth *string) *httptest.Server {
	t.Helper()
	body, err := os.ReadFile("testdata/diff.json")
	if err != nil {
		t.Fatal(err)
	}
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			t.Errorf("method = %s, want POST", r.Method)
		}
		if r.URL.Path != "/v8/diff/" {
			t.Errorf("path = %s, want /v8/diff/", r.URL.Path)
		}
		if auth != nil {
			*auth = r.Header.Get("Authorization")
		}
		if got != nil {
			if err := json.NewDecoder(r.Body).Decode(got); err != nil {
				t.Errorf("decode request: %v", err)
			}
		}
		w.Header().Set("Content-Type", "application/json")
		w.Write(body)
	}))
}

func aug(day int) time.Time { return time.Date(2026, 8, day, 0, 0, 0, 0, time.UTC) }

func TestSinceSumsSpendingPerCurrency(t *testing.T) {
	var req diffRequest
	var auth string
	srv := fixtureServer(t, &req, &auth)
	defer srv.Close()

	c, err := New("tok", srv.URL, time.Second)
	if err != nil {
		t.Fatal(err)
	}
	s, err := c.Since(context.Background(), aug(1), aug(6))
	if err != nil {
		t.Fatal(err)
	}
	if auth != "Bearer tok" {
		t.Errorf("Authorization = %q", auth)
	}
	if req.ServerTimestamp != aug(1).Unix() {
		t.Errorf("serverTimestamp = %d, want the window start", req.ServerTimestamp)
	}
	// 1500 + 249.5 RUB spent, 12 EUR spent, 3000 RUB in. The transfer (t4), the
	// deleted row (t6) and July's salary (t3) are all excluded.
	want := map[string]float64{"RUB": 1749.5, "EUR": 12}
	if len(s.Spent) != 2 {
		t.Fatalf("spent = %+v, want two currencies", s.Spent)
	}
	for _, m := range s.Spent {
		if want[m.Currency] != m.Amount {
			t.Errorf("spent %s = %v, want %v", m.Currency, m.Amount, want[m.Currency])
		}
	}
	if len(s.Earned) != 1 || s.Earned[0].Amount != 3000 || s.Earned[0].Currency != "RUB" {
		t.Errorf("earned = %+v, want 3000 RUB (July's salary is outside the window)", s.Earned)
	}
	if s.Count != 4 {
		t.Errorf("count = %d, want 4 counted transactions", s.Count)
	}
	// Largest first, so the fact value does not churn between polls.
	if s.Spent[0].Currency != "RUB" {
		t.Errorf("spent order = %+v, want the largest amount first", s.Spent)
	}
}

// A window with nothing in it is NOT a zero. No transactions means no answer,
// and the caller must be able to tell the difference.
func TestSinceEmptyWindowIsNotAZero(t *testing.T) {
	srv := fixtureServer(t, nil, nil)
	defer srv.Close()
	c, _ := New("tok", srv.URL, time.Second)
	s, err := c.Since(context.Background(), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC))
	if err != nil {
		t.Fatal(err)
	}
	if !s.Empty() {
		t.Fatalf("summary = %+v, want empty", s)
	}
	if _, ok := s.Value(time.Now()); ok {
		t.Error("an empty summary must not produce a fact value")
	}
}

func TestSinceReportsHTTPFailureWithoutTheBody(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, `{"account":"acc-card","secret":"leaky"}`, http.StatusUnauthorized)
	}))
	defer srv.Close()
	c, _ := New("tok", srv.URL, time.Second)
	_, err := c.Since(context.Background(), aug(1), aug(6))
	if err == nil {
		t.Fatal("want an error on 401")
	}
	if strings.Contains(err.Error(), "acc-card") || strings.Contains(err.Error(), "leaky") {
		t.Errorf("error %q echoes the response body — it reaches the log", err)
	}
}

func TestNewRequiresAToken(t *testing.T) {
	if _, err := New("  ", "", 0); err == nil {
		t.Error("want an error for an empty token — the capability is off unless configured")
	}
}

func TestMonthAndDayWindows(t *testing.T) {
	now := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC)
	from, to := MonthWindow(now)
	if from != time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) {
		t.Errorf("month window = %v..%v", from, to)
	}
	from, to = DayWindow(now)
	if from != time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) {
		t.Errorf("day window = %v..%v", from, to)
	}
}

func TestFactValueRoundTripAndFormat(t *testing.T) {
	s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3}
	raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
	if !ok {
		t.Fatal("want a fact value")
	}
	v, err := ParseFactValue(raw)
	if err != nil {
		t.Fatal(err)
	}
	got := v.FormatRU("в этом месяце")
	if !strings.Contains(got, "1749.5 RUB") || !strings.Contains(got, "3000 RUB") {
		t.Errorf("reply = %q, want the exact figures", got)
	}
	// Persona: informal, feminine, no commentary on his spending.
	for _, bad := range []string{"вы", "ваш", "милый", "дорогой", "рад ", "слишком", "много"} {
		if strings.Contains(got, bad) {
			t.Errorf("reply %q contains %q", got, bad)
		}
	}
	if strings.Contains(got, "он ") {
		t.Errorf("reply %q talks about him in the third person", got)
	}
}

// An empty fact value renders to nothing, so a caller cannot accidentally
// speak a zero.
func TestFormatRUEmptyRendersNothing(t *testing.T) {
	if got := (FactValue{}).FormatRU("сегодня"); got != "" {
		t.Errorf("reply = %q, want empty", got)
	}
}

func TestFormatAmountKeepsTheTruth(t *testing.T) {
	for in, want := range map[float64]string{1500: "1500", 249.5: "249.5", 0.99: "0.99", 1749.55: "1749.55"} {
		if got := formatAmount(in); got != want {
			t.Errorf("formatAmount(%v) = %q, want %q", in, got, want)
		}
	}
}

// The day window rolls over at midnight and the first spend of the new day may
// be hours away, so the last good money_today fact keeps a fresh ts while
// covering yesterday. Only the window stamp inside the value can tell.
func TestFactValueCoversDay(t *testing.T) {
	from, _ := DayWindow(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
	s := Summary{From: from, Spent: []Money{{"RUB", 1749.5}}, Count: 1}
	raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
	if !ok {
		t.Fatal("want a fact value")
	}
	v, err := ParseFactValue(raw)
	if err != nil {
		t.Fatal(err)
	}
	if !v.CoversDay(time.Date(2026, 8, 1, 23, 59, 0, 0, time.UTC)) {
		t.Error("the same day must be covered")
	}
	if v.CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
		t.Error("yesterday's day total must not count as today's")
	}
	if (FactValue{}).CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
		t.Error("a value with no window stamp must fail closed")
	}
	if v.AsOf.IsZero() {
		t.Error("the value must carry when it was read, not only when it changed")
	}
}

// The instrument rows are only in the diff when they changed since the cursor,
// which for a day window they usually have not. An amount whose currency
// nothing could name is dropped from the spoken string rather than read out
// as "1749.5 ?".
func TestUnknownCurrencyIsNotSpoken(t *testing.T) {
	v := FactValue{Spent: []Money{{UnknownCurrency, 1749.5}}, Count: 1}
	if got := v.FormatRU("сегодня"); got != "" {
		t.Errorf("reply = %q, want nothing said about an unlabelled amount", got)
	}
	v = FactValue{Spent: []Money{{"RUB", 100}, {UnknownCurrency, 1749.5}}, Count: 2}
	got := v.FormatRU("сегодня")
	if strings.Contains(got, UnknownCurrency) {
		t.Errorf("reply = %q, want no %q currency", got, UnknownCurrency)
	}
	if !strings.Contains(got, "100 RUB") {
		t.Errorf("reply = %q, want the amount that does have a currency", got)
	}
}

// A day diff cursored at midnight usually carries no instrument rows at all.
// The client fetches them once from a cursor-zero diff instead of labelling
// every amount "?".
func TestSinceResolvesCurrencyFromASeparateDiff(t *testing.T) {
	body, err := os.ReadFile("testdata/diff.json")
	if err != nil {
		t.Fatal(err)
	}
	var full diffResponse
	if err := json.Unmarshal(body, &full); err != nil {
		t.Fatal(err)
	}
	windowed := diffResponse{ServerTimestamp: full.ServerTimestamp, Transaction: full.Transaction}
	instrumentsOnly := diffResponse{ServerTimestamp: full.ServerTimestamp, Instrument: full.Instrument}
	zeroCursorCalls := 0
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var req diffRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			t.Errorf("decode request: %v", err)
		}
		out := windowed
		if req.ServerTimestamp == 0 {
			zeroCursorCalls++
			out = instrumentsOnly
		}
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(out)
	}))
	defer srv.Close()

	c, _ := New("tok", srv.URL, time.Second)
	s, err := c.Since(context.Background(), aug(1), aug(6))
	if err != nil {
		t.Fatal(err)
	}
	for _, m := range s.Spent {
		if m.Currency == UnknownCurrency {
			t.Fatalf("spent = %+v, want every amount named", s.Spent)
		}
	}
	// Cached for the process lifetime: a second window does not refetch.
	if _, err := c.Since(context.Background(), aug(1), aug(6)); err != nil {
		t.Fatal(err)
	}
	if zeroCursorCalls != 1 {
		t.Errorf("cursor-zero diffs = %d, want exactly 1", zeroCursorCalls)
	}
}
