// Package zenmoney reads spending and income from ZenMoney's /v8/diff/ API
// (Vikunja #125).
//
// Trust boundary: ZenMoney, not Maven. They already hold his bank sessions —
// this package only reads back what they have, over a token that lives in the
// poller module and is never handed to core. Nothing here writes to ZenMoney;
// diff is called read-only (an empty change set in, a change set out).
//
// Two rules the code exists to enforce:
//
//   - NEVER invent a number. Every figure in a Summary is a sum of amounts the
//     API returned. A request that fails, or returns nothing, produces no
//     summary and therefore no fact — silence, not a zero. A confidently wrong
//     "ты потратил 0" is worse than no answer.
//   - His money is never search input. This package holds no notes, no
//     utterances and no persona text, and it has no path to the external search
//     capability. The only thing that leaves the box here is the diff request
//     itself, to the service that already has the data.
package zenmoney

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"sort"
	"strings"
	"sync"
	"time"
)

// DefaultBaseURL — ZenMoney's API root. Overridable so the tests can point at
// an httptest server replaying a recorded response.
const DefaultBaseURL = "https://api.zenmoney.ru"

// Client is a ZenMoney diff reader. The token is held here, in the poller's
// address space; core never receives it and never learns it exists.
type Client struct {
	BaseURL string
	Token   string
	HTTP    *http.Client

	// instruments — id → short title, fetched once from a cursor-zero diff and
	// kept for the process lifetime. Currencies do not change; the reason this
	// cache exists is that a windowed diff only returns objects changed since
	// the cursor, so a day window almost never carries the instrument rows the
	// transactions in it point at.
	mu          sync.Mutex
	instruments map[int64]string
}

// New returns a client with a bounded HTTP timeout. An empty token is a
// programming error the caller must catch — the capability is off unless
// configured, so a client is only ever built when a token was supplied.
func New(token, baseURL string, timeout time.Duration) (*Client, error) {
	if strings.TrimSpace(token) == "" {
		return nil, fmt.Errorf("zenmoney: empty token")
	}
	if baseURL == "" {
		baseURL = DefaultBaseURL
	}
	if timeout <= 0 {
		timeout = 20 * time.Second
	}
	return &Client{
		BaseURL: strings.TrimRight(baseURL, "/"),
		Token:   token,
		HTTP:    &http.Client{Timeout: timeout},
	}, nil
}

// diffRequest — the smallest body /v8/diff/ accepts. serverTimestamp is the
// incremental cursor: the server returns objects changed at or after it.
type diffRequest struct {
	CurrentClientTimestamp int64 `json:"currentClientTimestamp"`
	ServerTimestamp        int64 `json:"serverTimestamp"`
}

// diffResponse — only the fields spending needs. ZenMoney returns a dozen more
// object types (tags, merchants, budgets, reminders); decoding them would mean
// holding more of his financial life in memory than the question needs.
type diffResponse struct {
	ServerTimestamp int64         `json:"serverTimestamp"`
	Instrument      []instrument  `json:"instrument"`
	Transaction     []transaction `json:"transaction"`
}

type instrument struct {
	ID         int64  `json:"id"`
	ShortTitle string `json:"shortTitle"`
}

type transaction struct {
	ID               string  `json:"id"`
	Date             string  `json:"date"` // "2026-07-15"
	Deleted          bool    `json:"deleted"`
	Income           float64 `json:"income"`
	Outcome          float64 `json:"outcome"`
	IncomeInstrument int64   `json:"incomeInstrument"`
	OutcomeInstrmnt  int64   `json:"outcomeInstrument"`
	IncomeAccount    string  `json:"incomeAccount"`
	OutcomeAccount   string  `json:"outcomeAccount"`
}

// Money — an amount in one currency. Kept as the currency's own short title
// ("RUB", "EUR") rather than converted: ZenMoney's rates are a snapshot, and
// converting would turn a figure he can check against his bank into one he
// cannot.
type Money struct {
	Currency string  `json:"currency"`
	Amount   float64 `json:"amount"`
}

// Summary — what was spent and earned over a window, per currency, plus how
// many transactions it was computed from. Count is the honesty check: a
// summary built from zero transactions is not "you spent nothing", it is "there
// was nothing to read", and callers treat it as no answer.
type Summary struct {
	From, To time.Time
	Spent    []Money `json:"spent"`
	Earned   []Money `json:"earned"`
	Count    int     `json:"count"`
	// ServerTimestamp — the cursor the API returned, for the caller to log or
	// carry. Not used as an incremental cursor for summaries; see Since.
	ServerTimestamp int64 `json:"-"`
}

// Since returns the summary of transactions dated in [from, to).
//
// The diff cursor is set to `from` so the server only sends objects changed
// since then, which for a "this month" window is everything filed this month.
// The caveat, deliberately accepted: a transaction he EDITED this month but
// dated last month arrives too, and is then excluded by date — so editing old
// records cannot inflate this month's total.
//
// The reverse case is real and undercounts: a transaction dated inside the
// window but last CHANGED before `from` never arrives. A planned transaction
// entered last month and dated this month is exactly that, and it goes missing
// from the total. Widening the cursor would mean pulling his whole history
// every poll, so the total is "what was filed or touched in the window", and
// that is the honest reading of it.
func (c *Client) Since(ctx context.Context, from, to time.Time) (Summary, error) {
	resp, err := c.diff(ctx, from.Unix())
	if err != nil {
		return Summary{}, err
	}
	names := c.currencyNames(ctx, resp)
	return summarize(resp, names, from, to), nil
}

// currencyNames resolves instrument ids to short titles. The window's own diff
// first, then — only if a transaction in it points at an instrument the window
// did not carry — one cursor-zero diff, cached for the process lifetime.
//
// A failed instrument fetch is not an error: the summary is still every number
// the API returned, and an amount whose currency cannot be named is dropped
// from the spoken string rather than read out as "1749.5 ?".
func (c *Client) currencyNames(ctx context.Context, resp diffResponse) map[int64]string {
	names := map[int64]string{}
	for _, in := range resp.Instrument {
		names[in.ID] = in.ShortTitle
	}
	missing := false
	for _, t := range resp.Transaction {
		if t.Deleted {
			continue
		}
		for _, id := range []int64{t.OutcomeInstrmnt, t.IncomeInstrument} {
			if id != 0 && names[id] == "" {
				missing = true
			}
		}
	}
	if !missing {
		return names
	}
	for id, title := range c.allInstruments(ctx) {
		if names[id] == "" {
			names[id] = title
		}
	}
	return names
}

// allInstruments fetches every instrument once, from a cursor-zero diff, and
// caches it. The response also carries transactions, which are decoded and
// dropped: this is the one call in the package that reads more of his financial
// life than the question needs, and it happens at most once per process.
func (c *Client) allInstruments(ctx context.Context) map[int64]string {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.instruments != nil {
		return c.instruments
	}
	resp, err := c.diff(ctx, 0)
	if err != nil {
		// Not cached: a network failure is not a fact about his currencies.
		return nil
	}
	c.instruments = make(map[int64]string, len(resp.Instrument))
	for _, in := range resp.Instrument {
		c.instruments[in.ID] = in.ShortTitle
	}
	return c.instruments
}

func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) {
	body, err := json.Marshal(diffRequest{
		CurrentClientTimestamp: time.Now().Unix(),
		ServerTimestamp:        serverTimestamp,
	})
	if err != nil {
		return diffResponse{}, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v8/diff/", bytes.NewReader(body))
	if err != nil {
		return diffResponse{}, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.Token)
	hc := c.HTTP
	if hc == nil {
		hc = &http.Client{Timeout: 20 * time.Second}
	}
	res, err := hc.Do(req)
	if err != nil {
		return diffResponse{}, err
	}
	defer res.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(res.Body, 32<<20))
	if err != nil {
		return diffResponse{}, err
	}
	if res.StatusCode != http.StatusOK {
		// The status only. The body of a failed diff can echo account data, and
		// this string reaches the log.
		return diffResponse{}, fmt.Errorf("zenmoney diff: %s", res.Status)
	}
	var out diffResponse
	if err := json.Unmarshal(raw, &out); err != nil {
		return diffResponse{}, fmt.Errorf("zenmoney diff: decode: %w", err)
	}
	return out, nil
}

// summarize sums the transactions dated inside the window.
//
// Excluded, in order: deleted rows (ZenMoney tombstones rather than removes),
// transfers and currency exchanges (income and outcome both non-zero — moving
// his own money between his own accounts is not spending), and anything dated
// outside the window.
func summarize(resp diffResponse, cur map[int64]string, from, to time.Time) Summary {
	spent := map[string]float64{}
	earned := map[string]float64{}
	count := 0
	for _, t := range resp.Transaction {
		if t.Deleted {
			continue
		}
		d, err := time.ParseInLocation("2006-01-02", t.Date, from.Location())
		if err != nil {
			continue // an undated row is not a number we can place
		}
		if d.Before(from) || !d.Before(to) {
			continue
		}
		if t.Income > 0 && t.Outcome > 0 {
			continue // transfer / exchange
		}
		switch {
		case t.Outcome > 0:
			spent[currency(cur, t.OutcomeInstrmnt)] += t.Outcome
			count++
		case t.Income > 0:
			earned[currency(cur, t.IncomeInstrument)] += t.Income
			count++
		}
	}
	return Summary{
		From: from, To: to,
		Spent: sortMoney(spent), Earned: sortMoney(earned),
		Count: count, ServerTimestamp: resp.ServerTimestamp,
	}
}

// UnknownCurrency — the label for an instrument id nothing could name. It
// survives into the Summary so a caller can see that a bucket exists; the
// renderer drops it rather than reading "?" aloud as a currency.
const UnknownCurrency = "?"

// currency names the instrument, or says it does not know. An unknown id keeps
// its bucket in the Summary rather than being folded into a named one: a sum is
// only checkable against his bank if every amount in it is in one currency.
func currency(names map[int64]string, id int64) string {
	if s := names[id]; s != "" {
		return s
	}
	return UnknownCurrency
}

// sortMoney gives the amounts a stable order (largest first) so the rendered
// string and the written fact do not churn between polls.
func sortMoney(m map[string]float64) []Money {
	out := make([]Money, 0, len(m))
	for c, a := range m {
		out = append(out, Money{Currency: c, Amount: a})
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Amount != out[j].Amount {
			return out[i].Amount > out[j].Amount
		}
		return out[i].Currency < out[j].Currency
	})
	return out
}

// Empty reports whether the summary rests on no transactions at all. Callers
// must treat an empty summary as "nothing to say", never as a zero: the
// difference between "he spent nothing" and "the read returned nothing" is the
// difference between an answer and an invented one.
func (s Summary) Empty() bool { return s.Count == 0 }

// MonthWindow — the first instant of now's month, and now's own day-end
// exclusive bound, in now's location. The window a "сколько я потратил в этом
// месяце?" question means.
func MonthWindow(now time.Time) (from, to time.Time) {
	loc := now.Location()
	from = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
	to = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
	return from, to
}

// DayWindow — today, in now's location.
func DayWindow(now time.Time) (from, to time.Time) {
	loc := now.Location()
	from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
	return from, from.AddDate(0, 0, 1)
}
