package reports

import (
	"context"
	"encoding/csv"
	"fmt"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/pkg/httpresponse"
	"github.com/TaruLab/keluargamart-backend/pkg/logger"
	"github.com/TaruLab/keluargamart-backend/pkg/middleware"
	"github.com/shopspring/decimal"
)

// monthLabels are short Indonesian month names for report rows.
var monthLabels = []string{"Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"}

// Handler exposes dashboard KPIs and CSV exports.
type Handler struct {
	svc *Service
}

func NewHandler(svc *Service) *Handler {
	return &Handler{svc: svc}
}

// KPIs serves GET /dashboard/kpis for any authenticated user (§10).
func (h *Handler) KPIs(w http.ResponseWriter, r *http.Request) {
	k, err := h.svc.KPIs(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), k)
}

// Charts serves GET /dashboard/charts for frontend dashboard visualization.
func (h *Handler) Charts(w http.ResponseWriter, r *http.Request) {
	k, err := h.svc.KPIs(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}

	labels := make([]string, 0, len(k.SalesChart))
	data := make([]float64, 0, len(k.SalesChart))
	orders := make([]int, 0, len(k.SalesChart))
	for _, pt := range k.SalesChart {
		labels = append(labels, pt.Month)
		f, _ := pt.Total.Float64()
		data = append(data, f)
		orders = append(orders, pt.Orders)
	}

	charts := map[string]any{
		"revenueTrend": map[string]any{
			"labels": labels,
			"data":   data,
			"orders": orders,
		},
		// Optional seam (same pattern as POSDashboard): the concrete
		// repository supplies real series; test fakes keep empty defaults.
		"cashflow": map[string]any{
			"income":  []float64{},
			"expense": []float64{},
		},
		"categorySales":   []map[string]any{},
		"topProducts":     []map[string]any{},
		"recentOrders":    []any{},
		"activeOrders":    []any{},
		"todaySales":      []any{},
		"pendingInvoices": []any{},
		"activePOs":       []any{},
		"recentNOO":       k.RecentNOO,
		"aoPerformance":   k.AOPerformance,
		"ao":              k.AO,
		"noo":             k.NOO,
		"stockStatus": map[string]any{
			"lowStockList": []any{},
		},
	}
	if ex, ok := h.svc.store.(interface {
		ChartsExtras(ctx context.Context, cashFrom, catFrom, catTo, today time.Time) (*ChartsExtras, error)
	}); ok {
		now := time.Now().In(h.svc.loc)
		cashFrom := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, h.svc.loc).AddDate(0, -11, 0)
		catFrom := cashFrom.AddDate(0, 11, 0)
		catTo := catFrom.AddDate(0, 1, 0)
		today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, h.svc.loc)
		// The 12 cashflow slots are cashFrom … current month, in order. The
		// labels MUST come from the same window: the ERP modal used a fixed
		// Jan…Dec list, so on 1 November every figure sat one slot off and the
		// current month was labelled "Des" (reported 1 Nov).
		cashMonths := make([]string, 0, 12)
		for i := 0; i < 12; i++ {
			cashMonths = append(cashMonths, cashFrom.AddDate(0, i, 0).Format("2006-01"))
		}

		res, err := ex.ChartsExtras(r.Context(), cashFrom, catFrom, catTo, today)
		if err == nil && res != nil {
			top := make([]map[string]any, 0, len(res.TopProducts))
			for _, tp := range res.TopProducts {
				f, _ := tp.Units.Float64()
				top = append(top, map[string]any{"name": tp.Name, "quantity": f})
			}
			charts["cashflow"] = map[string]any{
				"income":  res.CashflowIncome,
				"expense": res.CashflowExpense,
				"months":  cashMonths,
			}
			charts["topProducts"] = top
			charts["categorySales"] = res.CategorySales
			charts["recentOrders"] = res.RecentOrders
			charts["activeOrders"] = res.ActiveOrders
			charts["todaySales"] = res.TodaySales
			charts["stockStatus"] = map[string]any{"lowStockList": res.LowStock}
			charts["stockValueProducts"] = res.StockValueProducts
			charts["pendingInvoices"] = res.PendingInvoices
			charts["activePOs"] = res.ActivePOs
		}
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), charts)
}

// POSDashboard serves GET /dashboard/pos — the POS home dashboard. Optional
// seam like SalesReportExtras: concrete repository supplies it, fakes skip.
func (h *Handler) POSDashboard(w http.ResponseWriter, r *http.Request) {
	ex, ok := h.svc.store.(interface {
		POSDashboard(ctx context.Context, today time.Time, tzName string) (*POSDashboard, error)
	})
	if !ok {
		httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{})
		return
	}
	now := time.Now().In(h.svc.loc)
	d, err := ex.POSDashboard(r.Context(), now, h.svc.loc.String())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	resp := map[string]any{
		"revenueToday":       d.RevenueToday,
		"revenueDelta":       d.RevenueDelta,
		"transactionsToday":  d.TransactionsToday,
		"transactionsDelta":  d.TransactionsDelta,
		"itemsToday":         d.ItemsToday,
		"lowStockCount":      d.LowStockCount,
		"lowStock":           d.LowStock,
		"revenueTrend":       d.RevenueTrend,
		"dailyTransactions":  d.DailyTransactions,
		"topProducts":        d.TopProducts,
		"recentTransactions": d.RecentTransactions,
		"todaySales":         d.TodaySales,
		"todayItems":         d.TodayItems,
		"dayBreakdown":       d.DayBreakdown,
		"todayBreakdown":     map[string]decimal.Decimal{"market": d.MarketToday, "galon": d.GalonToday},
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), resp)
}

// ProfitLoss returns the yearly profit-and-loss payload for the ERP
// (revenue/cogs/margins, 12 monthly bars, expense split by cashflow category).
func (h *Handler) ProfitLoss(w http.ResponseWriter, r *http.Request) {
	year, _ := strconv.Atoi(r.URL.Query().Get("year"))
	if year == 0 {
		year = time.Now().Year()
	}
	from := time.Date(year, 1, 1, 0, 0, 0, 0, h.svc.loc)
	to := from.AddDate(1, 0, 0)

	revenue, err := h.svc.store.MonthSales(r.Context(), from, to)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	cogs, err := h.svc.store.MonthPurchase(r.Context(), from, to)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	chart, err := h.svc.store.SalesChart(r.Context(), from, 12)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	cfRows, err := h.svc.store.CashflowBreakdown(r.Context(), from, to)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}

	revByMonth := make(map[string]decimal.Decimal, len(chart))
	for _, pt := range chart {
		revByMonth[pt.Month] = pt.Total
	}
	expByMonth := map[string]decimal.Decimal{}
	expByCat := map[string]decimal.Decimal{}
	opex := decimal.Zero // expenses excluding purchases: purchases are already the COGS line
	for _, rw := range cfRows {
		if rw.Type != "expense" {
			continue
		}
		expByMonth[rw.Month] = expByMonth[rw.Month].Add(rw.Total)
		expByCat[rw.Category] = expByCat[rw.Category].Add(rw.Total)
		if rw.Category != "purchase" {
			opex = opex.Add(rw.Total)
		}
	}
	months := make([]map[string]any, 0, 12)
	for i, name := range monthLabels {
		key := fmt.Sprintf("%04d-%02d", year, i+1)
		rev, exp := revByMonth[key], expByMonth[key]
		months = append(months, map[string]any{"label": name, "revenue": rev, "expense": exp, "profit": rev.Sub(exp)})
	}

	grossProfit := revenue.Sub(cogs)
	margin := 0.0
	if revenue.Sign() > 0 {
		margin, _ = grossProfit.Div(revenue).Float64()
		margin *= 100
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{
		"revenue":             revenue,
		"cost_of_goods":       cogs,
		"cogs":                cogs,
		"gross_profit":        grossProfit,
		"gross_margin":        margin,
		"net_profit":          grossProfit.Sub(opex),
		"months":              months,
		"expense_by_category": expByCat,
		"period":              strconv.Itoa(year),
	})
}

// BalanceSheet returns the ERP neraca snapshot: assets (cash, receivables,
// inventory), liabilities (supplier payables) and balancing equity.
func (h *Handler) BalanceSheet(w http.ResponseWriter, r *http.Request) {
	receivables, err := h.svc.store.ReceivablesBalance(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	payables, err := h.svc.store.PayablesBalance(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	cash, inventory, err := h.svc.store.BalanceSheetSnapshot(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	fixedAssets, err := h.svc.store.FixedAssetsBalance(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	scope := auth.StoreScopeFrom(r.Context())
	scoped := !scope.PlatformWide()
	var lowStock int
	if scopedStore, hasScoped := h.svc.store.(interface {
		LowStockCountScoped(ctx context.Context, storeIDs []int64) (int, error)
	}); scoped && hasScoped {
		lowStock, _ = scopedStore.LowStockCountScoped(r.Context(), scope.IDs())
	} else {
		lowStock, _ = h.svc.store.LowStockCount(r.Context())
	}
	assets := cash.Add(receivables).Add(inventory).Add(fixedAssets)
	equity := assets.Sub(payables)
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{
		"date": time.Now().In(h.svc.loc).Format("2006-01-02"),
		"assets": map[string]any{
			"current": map[string]any{"cash": cash, "receivable": receivables, "inventory": inventory},
			"fixed":   map[string]any{"fixed_assets": fixedAssets},
			"total":   assets,
		},
		"liabilities":              map[string]any{"payable": payables, "total": payables},
		"equity":                   map[string]any{"retained": equity, "total": equity},
		"total_liabilities_equity": assets,
		"low_stock_count":          lowStock,
	})
}

// SalesReport returns order/revenue totals plus per-category and per-product
// breakdowns for a month or whole year.
func (h *Handler) SalesReport(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	year, _ := strconv.Atoi(q.Get("year"))
	month, _ := strconv.Atoi(q.Get("month"))
	if year == 0 {
		year = time.Now().Year()
	}
	var from, to time.Time
	if month == 0 { // no month selected → whole year
		from = time.Date(year, 1, 1, 0, 0, 0, 0, h.svc.loc)
		to = from.AddDate(1, 0, 0)
	} else {
		from = time.Date(year, time.Month(month), 1, 0, 0, 0, 0, h.svc.loc)
		to = from.AddDate(0, 1, 0)
	}
	// A store-bound caller (kepala toko) sees only its own outlet's sales.
	scope := auth.StoreScopeFrom(r.Context())
	scoped := !scope.PlatformWide()
	var rows []BreakdownRow
	var err error
	if sc, ok := h.svc.store.(interface {
		SalesBreakdownScoped(ctx context.Context, from, to time.Time, storeIDs []int64) ([]BreakdownRow, error)
	}); scoped && ok {
		rows, err = sc.SalesBreakdownScoped(r.Context(), from, to, scope.IDs())
	} else {
		rows, err = h.svc.store.SalesBreakdown(r.Context(), from, to)
	}
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}

	orders := 0
	revenue := decimal.Zero
	byCategory := []map[string]any{}
	byProduct := []map[string]any{}
	for _, rw := range rows {
		switch rw.Kind {
		case "total":
			orders, revenue = rw.Orders, rw.Total
		case "category":
			byCategory = append(byCategory, map[string]any{"label": rw.Label, "value": rw.Total})
		case "product":
			byProduct = append(byProduct, map[string]any{"label": rw.Label, "value": rw.Total})
		}
	}
	descVal := func(m []map[string]any) {
		sort.Slice(m, func(i, j int) bool {
			return m[i]["value"].(decimal.Decimal).GreaterThan(m[j]["value"].(decimal.Decimal))
		})
	}
	descVal(byCategory)
	descVal(byProduct)

	resp := map[string]any{
		"total_orders":   orders,
		"total_revenue":  revenue,
		"period_revenue": revenue,
		"by_category":    byCategory,
		"by_product":     byProduct,
		"period":         from.Format("2006-01"),
	}
	// Optional seam: the concrete repository can supply POS laporan
	// breakdowns; fakes and alternate stores keep working without them.
	// Skipped for a store-bound caller — these extras are company-wide.
	if ex, ok := h.svc.store.(interface {
		SalesReportExtras(ctx context.Context, from, to time.Time) (*SalesExtras, error)
	}); ok && !scoped {
		if ex2, err := ex.SalesReportExtras(r.Context(), from, to); err == nil && ex2 != nil {
			resp["items_sold"] = ex2.ItemsSold
			resp["status_count"] = map[string]int{"done": ex2.Done, "pending": ex2.Pending, "cancelled": ex2.Cancelled}
			resp["by_day"] = ex2.ByDay
			resp["by_payment"] = ex2.ByPayment
			resp["top_units"] = ex2.TopUnits
			resp["day_breakdown"] = ex2.DaySplit
			resp["market_total"] = ex2.Market
			resp["galon_total"] = ex2.Galon
		}
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), resp)
}

// StockReport returns total quantity/value of on-hand stock and its value
// split by product category.
func (h *Handler) StockReport(w http.ResponseWriter, r *http.Request) {
	// A store-bound caller (kepala toko) sees only its own outlet's stock.
	scope := auth.StoreScopeFrom(r.Context())
	scoped := !scope.PlatformWide()
	scopedStore, hasScoped := h.svc.store.(interface {
		StockBreakdownScoped(ctx context.Context, storeIDs []int64) ([]BreakdownRow, error)
		LowStockCountScoped(ctx context.Context, storeIDs []int64) (int, error)
	})

	var lowStock int
	var err error
	if scoped && hasScoped {
		lowStock, err = scopedStore.LowStockCountScoped(r.Context(), scope.IDs())
	} else {
		lowStock, err = h.svc.store.LowStockCount(r.Context())
	}
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	var rows []BreakdownRow
	if scoped && hasScoped {
		rows, err = scopedStore.StockBreakdownScoped(r.Context(), scope.IDs())
	} else {
		rows, err = h.svc.store.StockBreakdown(r.Context())
	}
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	totalQty, totalValue := decimal.Zero, decimal.Zero
	byCategory := []map[string]any{}
	for _, rw := range rows {
		if rw.Kind == "total" {
			totalQty, totalValue = rw.Qty, rw.Total
			continue
		}
		byCategory = append(byCategory, map[string]any{"label": rw.Label, "value": rw.Total})
	}
	sort.Slice(byCategory, func(i, j int) bool {
		return byCategory[i]["value"].(decimal.Decimal).GreaterThan(byCategory[j]["value"].(decimal.Decimal))
	})
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{
		"total_qty":       totalQty,
		"total_value":     totalValue,
		"by_category":     byCategory,
		"low_stock_count": lowStock,
	})
}

// FakturisasiReport returns invoice receivable totals for a year
// (count, value, paid, outstanding, overdue).
func (h *Handler) FakturisasiReport(w http.ResponseWriter, r *http.Request) {
	year, _ := strconv.Atoi(r.URL.Query().Get("year"))
	rows, err := h.svc.store.ReceivableRows(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	today := time.Now().In(h.svc.loc).Format("2006-01-02")
	count := 0
	value, paid, outstanding, overdue := decimal.Zero, decimal.Zero, decimal.Zero, decimal.Zero
	for _, rw := range rows {
		if year != 0 && !strings.HasPrefix(rw.Date, strconv.Itoa(year)) {
			continue
		}
		count++
		value = value.Add(rw.Amount)
		paid = paid.Add(rw.PaidAmount)
		outstanding = outstanding.Add(rw.Remaining)
		if rw.Remaining.Sign() > 0 && rw.DueDate != "" && rw.DueDate < today {
			overdue = overdue.Add(rw.Remaining)
		}
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{
		"total":       count,
		"total_value": value,
		"paid":        paid,
		"outstanding": outstanding,
		"overdue":     overdue,
	})
}

// SummaryReport returns POS dashboard summary (KPIs).
func (h *Handler) SummaryReport(w http.ResponseWriter, r *http.Request) {
	k, err := h.svc.KPIs(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), k)
}

// GetAnalytics returns admin analytics data (orders over time, top products, etc.).
func (h *Handler) GetAnalytics(w http.ResponseWriter, r *http.Request) {
	k, err := h.svc.KPIs(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	analytics := map[string]any{
		"kpis": map[string]any{
			"orders":    k.Sales.PendingOrders,
			"revenue":   k.MonthSales,
			"customers": k.HR.EmployeesCount,
			"lowStock":  k.LowStockCount,
		},
		"ordersOverTime":     k.SalesChart,
		"orderValueOverTime": k.SalesChart,
		"topProducts":        []any{},
		"topCustomers":       []any{},
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), analytics)
}

// ExportReceivables/OExportDebts/OExportCashflow serve GET /reports/<kind>.
// Accept: text/csv → RFC-4180 with UTF-8 BOM (api-design §9), else JSON rows.
func (h *Handler) ExportReceivables(w http.ResponseWriter, r *http.Request) {
	h.export(w, r, RowReceivable, ReportFilter{})
}

func (h *Handler) ExportDebts(w http.ResponseWriter, r *http.Request) {
	h.export(w, r, RowDebt, ReportFilter{})
}

// ExportCashflow is READ PATH 5 of 5, and the one that most needs the scoping:
// cashier holds finance.cashflows.export (000080:17-28), so before migration
// 000104 this was a company-wide cashflow dump, and it leaves as a FILE rather
// than a paginated JSON body.
//
// The filter comes from the caller's store scope, so it is threaded through
// ReportFilter and never read from the query string. A ?store_id= on the URL
// would let a caller narrow the export to a store it does not own, which is
// harmless on its own, but the reason the field is absent is that the next
// person to add it will assume it widens.
func (h *Handler) ExportCashflow(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	storeIDs, err := cashflowStoreScope(r.Context())
	if err != nil {
		// 403 with a safe message: the caller IS authenticated and this class of
		// resource is known to it, so a 404 would be a different lie. The error
		// is not echoed — it names the scoping mechanism, which is not the
		// caller's business.
		httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeForbidden, "Forbidden", nil)
		return
	}
	h.export(w, r, RowCashflow, ReportFilter{
		DateFrom: q.Get("date_from"), DateTo: q.Get("date_to"), StoreIDs: storeIDs,
	})
}

func (h *Handler) export(w http.ResponseWriter, r *http.Request, kind RowType, f ReportFilter) {
	rows, err := h.svc.Export(r.Context(), kind, f)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	if wantsCSV(r) {
		writeCSV(w, kind, rows)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), rows)
}

func wantsCSV(r *http.Request) bool {
	accept := r.Header.Get("Accept")
	for _, part := range splitAccept(accept) {
		if part == "text/csv" {
			return true
		}
	}
	return false
}

func splitAccept(s string) []string {
	out := []string{}
	start := 0
	for i := 0; i <= len(s); i++ {
		if i == len(s) || s[i] == ',' {
			part := s[start:i]
			if j := indexByte(part, ';'); j >= 0 {
				part = part[:j]
			}
			if part = trim(part); part != "" {
				out = append(out, part)
			}
			start = i + 1
		}
	}
	return out
}

func indexByte(s string, b byte) int {
	for i := 0; i < len(s); i++ {
		if s[i] == b {
			return i
		}
	}
	return -1
}

func trim(s string) string {
	for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
		s = s[1:]
	}
	for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
		s = s[:len(s)-1]
	}
	return s
}

// writeCSV streams RFC-4180 CSV with a UTF-8 BOM (Excel) and the columns of
// the Laravel exports (§9).
func writeCSV(w http.ResponseWriter, kind RowType, rows []CSVRow) {
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.csv", kind))
	if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
		return
	}
	cw := csv.NewWriter(w)
	_ = cw.Write(headerFor(kind))
	for _, r := range rows {
		_ = cw.Write(rowFor(kind, r))
	}
	cw.Flush()
}

func headerFor(kind RowType) []string {
	switch kind {
	case RowReceivable:
		return []string{"id", "invoice_number", "customer", "invoice_date", "due_date", "status", "total_amount", "paid_amount", "remaining_amount"}
	case RowDebt:
		return []string{"id", "po_number", "supplier", "order_date", "due_date", "status", "total_amount", "paid_amount", "remaining_amount"}
	case RowCashflow:
		return []string{"id", "transaction_number", "type", "category", "transaction_date", "amount"}
	default:
		return nil
	}
}

func rowFor(kind RowType, r CSVRow) []string {
	f2 := func(d decimal.Decimal) string { return d.StringFixed(2) }
	switch kind {
	case RowReceivable:
		return []string{strconv.FormatInt(r.ID, 10), r.Number, r.Partner, r.Date, r.DueDate, r.Status,
			f2(r.Amount), f2(r.PaidAmount), f2(r.Remaining)}
	case RowDebt:
		return []string{strconv.FormatInt(r.ID, 10), r.Number, r.Partner, r.Date, r.DueDate, r.Status,
			f2(r.Amount), f2(r.PaidAmount), f2(r.Remaining)}
	case RowCashflow:
		return []string{strconv.FormatInt(r.ID, 10), r.Number, r.Kind, r.Category, r.Date, f2(r.Amount)}
	default:
		return nil
	}
}

// CashflowStatement serves the journal-derived cash-flow statement for ?year=.
//
// Distinct from ExportCashflow above, which is a flat row export over payments,
// purchase orders and manual entries and has no notion of a section. This one
// answers "what did operating, investing and financing each do", which is what
// the ERP statement view renders.
//
// The year defaults to the current one; a missing or unparseable year is a 400
// rather than a silent fallback, because rendering last year's statement under
// this year's heading is the kind of wrong answer that reads as a right one.
func (h *Handler) CashflowStatement(w http.ResponseWriter, r *http.Request) {
	now := time.Now()
	year := now.Year()
	if raw := strings.TrimSpace(r.URL.Query().Get("year")); raw != "" {
		n, err := strconv.Atoi(raw)
		if err != nil || n < 2000 || n > 2100 {
			httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
				httpresponse.CodeValidation, "Validation", nil)
			return
		}
		year = n
	}
	stmt, err := h.svc.CashflowStatement(r.Context(), year)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), stmt)
}
