package reports

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

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/shopspring/decimal"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/db"
)

type fakeStore struct {
	monthSales, monthPurchase, receiv, payables decimal.Decimal
	todaySales                                  decimal.Decimal
	lowStock                                    int
	activities                                  []RecentActivity
	chart                                       []SalesChartPoint
	rows                                        []CSVRow
	cfRows                                      []CashflowBreakdownRow
	cash, inventory, fixedAssets                decimal.Decimal
	salesRows, stockRows                        []BreakdownRow
	activeAO                                    int
	existingCustomers                           int
	nooCustomers                                int
	totalCustomers                              int
	// cashflowScope records the ReportFilter handed to CashflowRows, so the
	// outlet-scoping tests can assert the scope REACHED the query rather than
	// only that a filter was constructed. A fake that ignored the filter would
	// make every scoping test here pass while the SQL stayed wide open.
	//
	// lastExportFilter records the filter handed to the receivables/debts
	// exports, which take none, so a test can assert this lane did not start
	// passing a store scope to a query with no store dimension.
	lastExportFilter *ReportFilter
	// cashflowScope is the observation hook the cashflow export uses instead of
	// a field, so a test can read the filter and also assert a call did NOT
	// happen (a field cannot distinguish "nil because platform-wide" from
	// "never called").
	cashflowScope func(ReportFilter)
}

func (f *fakeStore) MonthSales(context.Context, time.Time, time.Time) (decimal.Decimal, error) {
	return f.monthSales, nil
}
func (f *fakeStore) MonthPurchase(context.Context, time.Time, time.Time) (decimal.Decimal, error) {
	return f.monthPurchase, nil
}
func (f *fakeStore) ReceivablesBalance(context.Context) (decimal.Decimal, error) {
	return f.receiv, nil
}
func (f *fakeStore) PayablesBalance(context.Context) (decimal.Decimal, error) { return f.payables, nil }
func (f *fakeStore) LowStockCount(context.Context) (int, error)               { return f.lowStock, nil }
func (f *fakeStore) RecentActivity(context.Context, int) ([]RecentActivity, error) {
	return f.activities, nil
}
func (f *fakeStore) SalesChart(context.Context, time.Time, int) ([]SalesChartPoint, error) {
	return f.chart, nil
}
func (f *fakeStore) TodaySales(context.Context, time.Time) (decimal.Decimal, error) {
	// A real value, not monthSales/30: a fake that divides by 30 would make a
	// regressed KPI (the proxy this dashboard was fixed from) look correct.
	return f.todaySales, nil
}
func (f *fakeStore) PendingOrdersCount(context.Context) (int, error) {
	return len(f.activities), nil
}
func (f *fakeStore) ActivePOsCount(context.Context) (int, error) {
	return 2, nil
}
func (f *fakeStore) OverdueInvoicesCount(context.Context, time.Time) (int, error) {
	return 0, nil
}
func (f *fakeStore) MonthNetCashflow(context.Context, time.Time, time.Time) (decimal.Decimal, error) {
	return f.monthSales.Sub(f.monthPurchase), nil
}
func (f *fakeStore) ReceivableRows(_ context.Context) ([]CSVRow, error) { return f.rows, nil }
func (f *fakeStore) DebtRows(_ context.Context) ([]CSVRow, error)       { return f.rows, nil }

// CashflowRows records the filter it was given. The scope has to be observed
// here, at the boundary, because that is the last point before real SQL — a
// test asserting it in the handler would pass even if the repository dropped it.
func (f *fakeStore) CashflowRows(_ context.Context, flt ReportFilter) ([]CSVRow, error) {
	if f.cashflowScope != nil {
		f.cashflowScope(flt)
	}
	return f.rows, nil
}
func (f *fakeStore) CashflowBreakdown(_ context.Context, _, _ time.Time) ([]CashflowBreakdownRow, error) {
	return f.cfRows, nil
}

// Cash-flow statement support. faked rather than absent so the mock store keeps
// satisfying Store as the interface grows — the alternative is every mock in the
// package failing to compile the next time a method is added.
func (f *fakeStore) CashflowStatementMovements(_ context.Context, _ db.CashflowStatementMovementsParams) ([]db.CashflowStatementMovementsRow, error) {
	return nil, nil
}
func (f *fakeStore) CashAccountCodes(_ context.Context) ([]string, error) {
	return defaultCashCodes, nil
}
func (f *fakeStore) CashAccountBalanceBefore(_ context.Context, _ db.CashAccountBalanceBeforeParams) (decimal.Decimal, error) {
	return decimal.Zero, nil
}
func (f *fakeStore) BalanceSheetSnapshot(context.Context) (decimal.Decimal, decimal.Decimal, error) {
	return f.cash, f.inventory, nil
}
func (f *fakeStore) StockSummary(context.Context) (decimal.Decimal, int, error) {
	return f.inventory, 32, nil
}
func (f *fakeStore) FixedAssetsBalance(context.Context) (decimal.Decimal, error) {
	return f.fixedAssets, nil
}
func (f *fakeStore) SalesBreakdown(_ context.Context, _, _ time.Time) ([]BreakdownRow, error) {
	return f.salesRows, nil
}
func (f *fakeStore) StockBreakdown(context.Context) ([]BreakdownRow, error)           { return f.stockRows, nil }
func (f *fakeStore) NewCustomersCount(_ context.Context, _, _ time.Time) (int, error) { return 0, nil }
func (f *fakeStore) RecentCustomers(_ context.Context, _ int) ([]RecentCustomer, error) {
	return nil, nil
}
func (f *fakeStore) VisitStats(_ context.Context, _ time.Time) (int, int, error) { return 0, 0, nil }
func (f *fakeStore) ActivitiesPendingCount(_ context.Context) (int, error)       { return 0, nil }
func (f *fakeStore) CustomerCounts(_ context.Context, _, _ time.Time) (int, int, int, error) {
	return f.existingCustomers, f.nooCustomers, f.totalCustomers, nil
}
func (f *fakeStore) ActiveAOCount(_ context.Context, _, _ time.Time) (int, error) {
	return f.activeAO, nil
}
func (f *fakeStore) AOPerformance(_ context.Context, _ time.Time) ([]AOPerformanceRow, error) {
	return nil, nil
}

func loc() *time.Location { l, _ := time.LoadLocation("Asia/Jakarta"); return l }

func TestKPIs_Assembles(t *testing.T) {
	st := &fakeStore{
		monthSales: decimal.NewFromInt(1000), monthPurchase: decimal.NewFromInt(400),
		receiv: decimal.NewFromInt(250), payables: decimal.NewFromInt(75), lowStock: 3,
		activities: []RecentActivity{{ID: 1, Module: "sales", Action: "create", CreatedAt: "2026-08-11 10:00:00"}},
		chart:      []SalesChartPoint{{Month: "2026-08", Total: decimal.NewFromInt(1000)}},
	}
	svc := NewService(st, loc())
	k, err := svc.KPIs(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if k.MonthSales.String() != "1000" || k.MonthPurchase.String() != "400" {
		t.Errorf("sales/purchase = %s/%s, want 1000/400", k.MonthSales, k.MonthPurchase)
	}
	if k.Receivables.String() != "250" || k.Payables.String() != "75" {
		t.Errorf("receiv/payable = %s/%s, want 250/75", k.Receivables, k.Payables)
	}
	if k.LowStockCount != 3 || len(k.RecentActivity) != 1 || len(k.SalesChart) != 1 {
		t.Error("KPI sub-collections not populated")
	}
}

func TestCSVExport_WritesBOM(t *testing.T) {
	st := &fakeStore{rows: []CSVRow{
		{ID: 1, Number: "INV-1", Partner: "PT A", Date: "2026-08-01", DueDate: "2026-08-11",
			Status: "unpaid", Amount: decimal.NewFromInt(250), PaidAmount: decimal.Zero, Remaining: decimal.NewFromInt(250)},
	}}
	svc := NewService(st, loc())
	h := NewHandler(svc)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/receivables", nil)
	req.Header.Set("Accept", "text/csv")
	rr := httptest.NewRecorder()
	h.ExportReceivables(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rr.Code)
	}
	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
		t.Errorf("content-type = %s, want text/csv", ct)
	}
	body := rr.Body.Bytes()
	if len(body) < 3 || body[0] != 0xEF || body[1] != 0xBB || body[2] != 0xBF {
		t.Error("missing UTF-8 BOM")
	}
	out := string(body[3:])
	for _, want := range []string{"invoice_number", "INV-1", "PT A", "unpaid", "250.00"} {
		if !strings.Contains(out, want) {
			t.Errorf("CSV missing %q:\n%s", want, out)
		}
	}
}

func TestCSVExport_JSONFallback(t *testing.T) {
	st := &fakeStore{rows: []CSVRow{
		{ID: 1, Number: "CF-1", Kind: "income", Category: "sales", Date: "2026-08-01", Amount: decimal.NewFromInt(50)},
	}}
	svc := NewService(st, loc())
	h := NewHandler(svc)

	// platformCtx: the cashflow export is fail-closed since migration 000104 — a
	// request with no store scope attached is refused rather than treated as
	// unrestricted. This test is about the response format, so it says what it
	// means: a caller who may see every outlet.
	req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/cashflow", nil).
		WithContext(auth.WithStoreScope(context.Background(), auth.NewStoreScope(nil)))
	rr := httptest.NewRecorder()
	h.ExportCashflow(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rr.Code)
	}
	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		t.Errorf("content-type = %s, want application/json", ct)
	}
	if !strings.Contains(rr.Body.String(), `"number":"CF-1"`) {
		t.Errorf("JSON missing field: %s", rr.Body.String())
	}
}

func TestExport_UnknownKind(t *testing.T) {
	svc := NewService(&fakeStore{}, loc())
	rows, err := svc.Export(context.Background(), RowType("bogus"), ReportFilter{})
	if err != nil || rows != nil {
		t.Errorf("want nil,nil for unknown kind, got %v,%v", rows, err)
	}
}

// TestReports_FrontendShapes pins the payload keys the ERP report pages read.
func TestReports_FrontendShapes(t *testing.T) {
	d := decimal.NewFromInt
	st := &fakeStore{
		monthSales: d(1200), monthPurchase: d(700),
		chart: []SalesChartPoint{{Month: "2026-01", Total: d(100)}},
		cfRows: []CashflowBreakdownRow{
			{Month: "2026-01", Type: "expense", Category: "purchase", Total: d(50)},
			{Month: "2026-02", Type: "expense", Category: "operational", Total: d(30)},
			{Month: "2026-02", Type: "income", Category: "sales", Total: d(999)},
		},
		cash: d(100), inventory: d(200), receiv: d(40), payables: d(60),
		salesRows: []BreakdownRow{
			{Kind: "total", Orders: 7, Total: d(500)},
			{Kind: "category", Label: "Sembako", Total: d(300)},
			{Kind: "product", Label: "Beras 5kg", Total: d(150)},
		},
		stockRows: []BreakdownRow{
			{Kind: "total", Qty: d(25), Total: d(250)},
			{Kind: "category", Label: "Minuman", Total: d(90)},
		},
		rows: []CSVRow{
			{Number: "INV-1", Date: "2026-03-01", DueDate: "2020-01-01", Status: "partial",
				Amount: d(100), PaidAmount: d(30), Remaining: d(70)},
			{Number: "INV-2", Date: "2025-03-01", DueDate: "2020-01-01", Status: "unpaid",
				Amount: d(10), Remaining: d(10)},
		},
	}
	h := NewHandler(NewService(st, loc()))
	get := func(path string) map[string]any {
		t.Helper()
		req := httptest.NewRequest(http.MethodGet, path, nil)
		rr := httptest.NewRecorder()
		switch {
		case strings.HasPrefix(path, "/pl"):
			h.ProfitLoss(rr, req)
		case strings.HasPrefix(path, "/bs"):
			h.BalanceSheet(rr, req)
		case strings.HasPrefix(path, "/sales"):
			h.SalesReport(rr, req)
		case strings.HasPrefix(path, "/stock"):
			h.StockReport(rr, req)
		default:
			h.FakturisasiReport(rr, req)
		}
		if rr.Code != http.StatusOK {
			t.Fatalf("%s: status %d: %s", path, rr.Code, rr.Body.String())
		}
		var env struct {
			Data map[string]any `json:"data"`
		}
		if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
			t.Fatalf("%s: %v", path, err)
		}
		return env.Data
	}
	has := func(m map[string]any, keys ...string) {
		t.Helper()
		for _, k := range keys {
			if _, ok := m[k]; !ok {
				t.Errorf("missing key %q in %v", k, m)
			}
		}
	}

	pl := get("/pl?year=2026")
	has(pl, "revenue", "cogs", "gross_profit", "gross_margin", "net_profit", "months", "expense_by_category")
	if len(pl["months"].([]any)) != 12 {
		t.Error("profit-loss months must have 12 entries")
	}

	bs := get("/bs")
	assets := bs["assets"].(map[string]any)
	has(bs, "date", "liabilities", "equity", "total_liabilities_equity", "low_stock_count")
	has(assets, "current", "fixed", "total")

	sr := get("/sales?year=2026")
	has(sr, "total_orders", "total_revenue", "by_category", "by_product")
	if sr["total_orders"].(float64) != 7 {
		t.Errorf("total_orders = %v, want 7", sr["total_orders"])
	}

	stk := get("/stock")
	has(stk, "total_qty", "total_value", "by_category")

	fk := get("/fakturisasi?year=2026")
	has(fk, "total", "total_value", "paid", "outstanding", "overdue")
	if fk["total"].(float64) != 1 || fk["outstanding"] != "70" || fk["overdue"] != "70" {
		t.Errorf("fakturisasi totals = %v/%v/%v, want 1/70/70", fk["total"], fk["outstanding"], fk["overdue"])
	}
}

// Balance sheet fixed_assets reflects the net book value derived from posted
// journal lines against COA 120101/120102 (finance.Hardening's balance
// mechanism), not a hardcoded zero, and rolls up into assets/equity totals.
func TestBalanceSheet_FixedAssetsFromLedger(t *testing.T) {
	d := decimal.NewFromInt
	st := &fakeStore{cash: d(100), inventory: d(200), receiv: d(40), payables: d(60), fixedAssets: d(8000000)}
	h := NewHandler(NewService(st, loc()))
	req := httptest.NewRequest(http.MethodGet, "/bs", nil)
	rr := httptest.NewRecorder()
	h.BalanceSheet(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("status %d: %s", rr.Code, rr.Body.String())
	}
	var env struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
		t.Fatal(err)
	}
	assets := env.Data["assets"].(map[string]any)
	fixed := assets["fixed"].(map[string]any)
	if fixed["fixed_assets"] != "8000000" {
		t.Errorf("fixed_assets = %v, want 8000000 (from ledger, not hardcoded 0)", fixed["fixed_assets"])
	}
	// total assets must include fixed_assets: 100+200+40+8000000 = 8000340
	if assets["total"] != "8000340" {
		t.Errorf("assets.total = %v, want 8000340 (fixed_assets rolled into total)", assets["total"])
	}
	// equity = total assets - payables = 8000340 - 60 = 8000280
	equity := env.Data["equity"].(map[string]any)
	if equity["total"] != "8000280" {
		t.Errorf("equity.total = %v, want 8000280", equity["total"])
	}
}

func TestKPIs_AOCalculation(t *testing.T) {
	// Business formula: Total Customers = Existing ("jumlah yang ada") + NOO
	// Example from specification:
	// Existing = 4000, NOO = 10 -> Total Customers = 4010
	// Active AO = 110 -> AO % = 110 / 4010 = 2.743142...% (2.743%)
	st := &fakeStore{
		monthSales:        decimal.NewFromInt(1000),
		monthPurchase:     decimal.NewFromInt(400),
		receiv:            decimal.NewFromInt(250),
		payables:          decimal.NewFromInt(75),
		lowStock:          3,
		activities:        []RecentActivity{{ID: 1, Module: "sales", Action: "create", CreatedAt: "2026-08-11 10:00:00"}},
		chart:             []SalesChartPoint{{Month: "2026-08", Total: decimal.NewFromInt(1000)}},
		activeAO:          110,
		existingCustomers: 4000,
		nooCustomers:      10,
		totalCustomers:    4010,
	}
	svc := NewService(st, loc())
	k, err := svc.KPIs(context.Background())
	if err != nil {
		t.Fatal(err)
	}

	if k.AO.ActiveAO != 110 {
		t.Errorf("AO.ActiveAO = %d, want 110", k.AO.ActiveAO)
	}
	if k.AO.ExistingCustomers != 4000 {
		t.Errorf("AO.ExistingCustomers = %d, want 4000", k.AO.ExistingCustomers)
	}
	if k.AO.NOO != 10 {
		t.Errorf("AO.NOO = %d, want 10", k.AO.NOO)
	}
	if k.AO.TotalCustomers != 4010 {
		t.Errorf("AO.TotalCustomers = %d, want 4010", k.AO.TotalCustomers)
	}
	if k.AO.AOPct == nil {
		t.Fatal("AO.AOPct is nil, expected calculated float")
	}
	expectedPct := (110.0 / 4010.0) * 100.0 // 2.743142...
	if math.Abs(*k.AO.AOPct-expectedPct) > 0.0001 {
		t.Errorf("AO.AOPct = %f, want ~%f (2.743%%)", *k.AO.AOPct, expectedPct)
	}
}

func TestKPIs_AOCalculation_ZeroCustomers(t *testing.T) {
	st := &fakeStore{
		activeAO:          110,
		existingCustomers: 0,
		nooCustomers:      0,
		totalCustomers:    0,
	}
	svc := NewService(st, loc())
	k, err := svc.KPIs(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if k.AO.AOPct == nil {
		t.Fatal("AO.AOPct is nil, expected 0.0")
	}
	if *k.AO.AOPct != 0.0 {
		t.Errorf("AO.AOPct = %f, want 0.0", *k.AO.AOPct)
	}
}

func TestCalculateConversionRate(t *testing.T) {
	// 1. Normal conversion: (110 / 4010) * 100 = 2.743142144638404...
	rate := CalculateConversionRate(110, 4010)
	expected := (110.0 / 4010.0) * 100.0
	if math.Abs(rate-expected) > 0.000001 {
		t.Errorf("CalculateConversionRate(110, 4010) = %f, want %f", rate, expected)
	}

	// 2. Division by zero edge case: total customer = 0
	if r := CalculateConversionRate(110, 0); r != 0.0 {
		t.Errorf("CalculateConversionRate(110, 0) = %f, want 0.0", r)
	}

	// 3. Zero customer orders edge case: customer order = 0
	if r := CalculateConversionRate(0, 4010); r != 0.0 {
		t.Errorf("CalculateConversionRate(0, 4010) = %f, want 0.0", r)
	}

	// 4. Both zero
	if r := CalculateConversionRate(0, 0); r != 0.0 {
		t.Errorf("CalculateConversionRate(0, 0) = %f, want 0.0", r)
	}

	// 5. Negative values guard
	if r := CalculateConversionRate(-5, 4010); r != 0.0 {
		t.Errorf("CalculateConversionRate(-5, 4010) = %f, want 0.0", r)
	}
}

func (f *fakeStore) Pool() *pgxpool.Pool { return nil }
