package accounting

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

	"github.com/shopspring/decimal"

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

func d(v string) decimal.Decimal {
	x, _ := decimal.NewFromString(v)
	return x
}

// The journal_type tokens the frontend sends must all have number prefixes.
func TestJournalPrefixesCoverFrontendTokens(t *testing.T) {
	want := map[string]string{
		"general":                  "JU",
		"cash_disbursement":        "KK",
		"cash_receipt":             "KM",
		"bank_disbursement":        "BK",
		"bank_receipt":             "BM",
		"customer_deposit_receipt": "DP",
		"customer_deposit_usage":   "DK",
	}
	if len(journalPrefixes) != len(want) {
		t.Fatalf("journalPrefixes has %d entries, want %d: %v", len(journalPrefixes), len(want), journalPrefixes)
	}
	for typ, prefix := range want {
		if got := journalPrefixes[typ]; got != prefix {
			t.Errorf("journalPrefixes[%s] = %q, want %q", typ, got, prefix)
		}
	}
}

func TestNormalBalanceForType(t *testing.T) {
	cases := map[string]string{
		"asset": "debit", "expense": "debit",
		"liability": "credit", "equity": "credit", "revenue": "credit",
	}
	for typ, want := range cases {
		if got := normalBalanceForType(typ); got != want {
			t.Errorf("normalBalanceForType(%s) = %s, want %s", typ, got, want)
		}
	}
}

// Advance status is derived from allocated/refunded amounts.
func TestAdvanceStatus(t *testing.T) {
	cases := []struct {
		amount, allocated, refunded string
		want                        string
	}{
		{"1000000", "0", "0", "received"},
		{"1000000", "400000", "0", "partially_allocated"},
		{"1000000", "0", "100000", "partially_allocated"},
		{"1000000", "1000000", "0", "allocated"},
		{"1000000", "750000", "250000", "refunded"},
		{"1000000", "0", "1000000", "refunded"},
	}
	for _, c := range cases {
		got := advanceStatus(d(c.amount), d(c.allocated), d(c.refunded))
		if got != c.want {
			t.Errorf("advanceStatus(%s,%s,%s) = %s, want %s", c.amount, c.allocated, c.refunded, got, c.want)
		}
	}
}

// The AdvancesPage filter sends open/partial/used; the canonical enum is
// received/partially_allocated/allocated/refunded.
func TestLegacyAdvanceStatusMapping(t *testing.T) {
	want := map[string]string{
		"open": "received", "partial": "partially_allocated",
		"used": "allocated", "refunded": "refunded",
	}
	for legacy, canon := range want {
		if got := legacyAdvanceStatus[legacy]; got != canon {
			t.Errorf("legacyAdvanceStatus[%s] = %s, want %s", legacy, got, canon)
		}
	}
}

// Ledger CSV export: header, one row per entry, fixed 2-decimal amounts,
// attachment disposition.
func TestWriteLedgerCSV(t *testing.T) {
	report := &LedgerReport{
		DateFrom: "2026-09-01", DateTo: "2026-09-30",
		Accounts: []LedgerAccount{{
			AccountCode: "5000.01", AccountName: "Beban Listrik & Air", AccountType: "expense", NormalBalance: "debit",
			OpeningBalance: d("100"),
			Entries: []LedgerEntry{{
				ID: 1, EntryID: 2, Date: "2026-09-02", JournalNumber: "KK-202609-0002",
				JournalType: "cash_disbursement", Reference: "BKK-PLN-01", Description: "Tagihan listrik",
				Debit: d("450000"), Credit: d("0"), Balance: d("450100"),
			}},
		}},
		TotalDebit: d("450000"), TotalCredit: d("450000"),
	}
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/accounting/ledger?format=csv", nil)
	req = req.WithContext(context.WithValue(req.Context(), middleware.RequestIDKey, "test-req"))
	writeLedgerCSV(rec, req, report)

	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
		t.Errorf("Content-Type = %q, want text/csv", ct)
	}
	if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="ledger-2026-09-01-2026-09-30.csv"`) {
		t.Errorf("Content-Disposition = %q, want ledger date-ranged attachment", cd)
	}
	lines := strings.Split(strings.TrimSpace(rec.Body.String()), "\n")
	if len(lines) != 3 {
		t.Fatalf("CSV has %d lines, want 3 (header + entry + total):\n%s", len(lines), rec.Body.String())
	}
	if !strings.HasPrefix(lines[0], "account_code,account_name") {
		t.Errorf("CSV header = %q", lines[0])
	}
	if !strings.Contains(lines[1], "5000.01") || !strings.Contains(lines[1], "450000.00") || !strings.Contains(lines[1], "450100.00") {
		t.Errorf("CSV entry row = %q", lines[1])
	}
	if !strings.Contains(lines[2], "TOTAL") {
		t.Errorf("CSV total row = %q", lines[2])
	}
}

func TestValidateDateRange(t *testing.T) {
	if err := validateDateRange("", ""); err != nil {
		t.Errorf("empty range should pass, got %v", err)
	}
	if err := validateDateRange("2026-09-01", "2026-09-30"); err != nil {
		t.Errorf("valid range should pass, got %v", err)
	}
	if err := validateDateRange("09/01/2026", ""); err == nil {
		t.Error("non-ISO date_from should fail")
	}
	if err := validateDateRange("", "2026-13-01"); err == nil {
		t.Error("invalid date_to should fail")
	}
}

func TestParseDate(t *testing.T) {
	if _, err := parseDate("2026-09-09"); err != nil {
		t.Fatalf("valid date failed: %v", err)
	}
	if _, err := parseDate("2026-9-9"); err == nil {
		t.Error("loose date should fail")
	}
	if _, err := parseDate(time.Now().Format(time.RFC3339)); err == nil {
		t.Error("datetime should fail as date")
	}
}

func TestAllowedJournalTypesForRole(t *testing.T) {
	cashierTypes := allowedJournalTypesForRole(auth.RoleCashier)
	if len(cashierTypes) != 3 || cashierTypes[0] != "cash_disbursement" || cashierTypes[1] != "cash_receipt" || cashierTypes[2] != "general" {
		t.Errorf("cashier allowed types mismatch: %v", cashierTypes)
	}

	salesTypes := allowedJournalTypesForRole(auth.RoleSales)
	if len(salesTypes) != 3 || salesTypes[0] != "customer_deposit_receipt" || salesTypes[1] != "customer_deposit_usage" || salesTypes[2] != "general" {
		t.Errorf("sales allowed types mismatch: %v", salesTypes)
	}

	if allowedJournalTypesForRole(auth.RoleFinance) != nil {
		t.Errorf("finance should have unrestricted journal types (nil)")
	}
	if allowedJournalTypesForRole(auth.RoleAdmin) != nil {
		t.Errorf("admin should have unrestricted journal types (nil)")
	}

	whTypes := allowedJournalTypesForRole(auth.RoleWarehouse)
	if len(whTypes) != 1 || whTypes[0] != "general" {
		t.Errorf("warehouse operator should only have general: %v", whTypes)
	}
}

func TestValidateRoleForJournalType(t *testing.T) {
	// Kasir can input cash_disbursement, cash_receipt, general
	if err := validateRoleForJournalType(auth.RoleCashier, "cash_disbursement"); err != nil {
		t.Errorf("cashier should be able to create cash_disbursement, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleCashier, "cash_receipt"); err != nil {
		t.Errorf("cashier should be able to create cash_receipt, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleCashier, "general"); err != nil {
		t.Errorf("cashier should be able to create general: %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleCashier, "bank_disbursement"); err == nil {
		t.Error("cashier should NOT be able to create bank_disbursement")
	}
	if err := validateRoleForJournalType(auth.RoleCashier, "customer_deposit_receipt"); err == nil {
		t.Error("cashier should NOT be able to create customer_deposit_receipt")
	}

	// Accounting (finance) can input all journals
	if err := validateRoleForJournalType(auth.RoleFinance, "bank_disbursement"); err != nil {
		t.Errorf("finance should be able to create bank_disbursement, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleFinance, "bank_receipt"); err != nil {
		t.Errorf("finance should be able to create bank_receipt, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleFinance, "general"); err != nil {
		t.Errorf("finance should be able to create general, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleFinance, "cash_disbursement"); err != nil {
		t.Errorf("finance should be able to create cash_disbursement, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleFinance, "customer_deposit_receipt"); err != nil {
		t.Errorf("finance should be able to create customer_deposit_receipt, got %v", err)
	}

	// Piutang (sales) can input customer_deposit_receipt, customer_deposit_usage, general
	if err := validateRoleForJournalType(auth.RoleSales, "customer_deposit_receipt"); err != nil {
		t.Errorf("sales should be able to create customer_deposit_receipt, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleSales, "customer_deposit_usage"); err != nil {
		t.Errorf("sales should be able to create customer_deposit_usage, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleSales, "general"); err != nil {
		t.Errorf("sales should be able to create general, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleSales, "bank_disbursement"); err == nil {
		t.Error("sales should NOT be able to create bank_disbursement")
	}

	// Operator (e.g. warehouse) can input general
	if err := validateRoleForJournalType(auth.RoleWarehouse, "general"); err != nil {
		t.Errorf("warehouse operator should be able to create general, got %v", err)
	}
	if err := validateRoleForJournalType(auth.RoleWarehouse, "cash_disbursement"); err == nil {
		t.Error("warehouse operator should NOT be able to create cash_disbursement")
	}
}

func TestJournalBySourceContract(t *testing.T) {
	// Store-interface conformance: a fake implementing the extended Store
	// must satisfy the interface or the build fails here.
	var _ Store = (*sourceFake)(nil)
}

type sourceFake struct{ Store }

func (f *sourceFake) JournalBySource(_ context.Context, sourceType string, sourceID int64) (db.JournalEntry, error) {
	if sourceType != "goods_receipt" || sourceID != 7 {
		return db.JournalEntry{}, ErrNotFound
	}
	return db.JournalEntry{ID: 99}, nil
}

type rbacStoreFake struct {
	Store
	lastFilter JournalFilter
	journal    db.JournalEntry
}

func (f *rbacStoreFake) CountJournals(_ context.Context, flt JournalFilter) (int, error) {
	f.lastFilter = flt
	return 1, nil
}

func (f *rbacStoreFake) ListJournals(_ context.Context, flt JournalFilter) ([]db.JournalEntry, error) {
	f.lastFilter = flt
	return []db.JournalEntry{f.journal}, nil
}

func (f *rbacStoreFake) ListJournalLinesByEntries(_ context.Context, _ []int64) ([]db.AcctListJournalLinesByEntriesRow, error) {
	return nil, nil
}

func (f *rbacStoreFake) GetJournal(_ context.Context, id int64) (db.JournalEntry, error) {
	if f.journal.ID == 0 {
		return db.JournalEntry{}, ErrNotFound
	}
	return f.journal, nil
}

func (f *rbacStoreFake) GetAccountByCode(_ context.Context, _ db.DBTX, code string) (db.Account, error) {
	return db.Account{ID: 1, Code: code, IsPostable: true}, nil
}

func TestJournalRBACScoping(t *testing.T) {
	fake := &rbacStoreFake{
		journal: db.JournalEntry{ID: 10, JournalType: "cash_receipt"},
	}
	svc := NewService(fake, nil, time.UTC)

	// Cashier context
	cashierCtx := auth.ToContext(context.Background(), &auth.User{ID: 1, Role: auth.RoleCashier}, 1)

	// 1. ListJournals with no type: AllowedJournalTypes should be set to cashier types
	list, total, err := svc.ListJournals(cashierCtx, JournalFilter{})
	if err != nil {
		t.Fatalf("ListJournals error: %v", err)
	}
	if total != 1 || len(list) != 1 {
		t.Errorf("expected 1 journal, got %d (total %d)", len(list), total)
	}
	if len(fake.lastFilter.AllowedJournalTypes) != 3 ||
		fake.lastFilter.AllowedJournalTypes[0] != "cash_disbursement" ||
		fake.lastFilter.AllowedJournalTypes[1] != "cash_receipt" ||
		fake.lastFilter.AllowedJournalTypes[2] != "general" {
		t.Errorf("expected cashier allowed types in filter, got %v", fake.lastFilter.AllowedJournalTypes)
	}

	// 2. ListJournals requesting unauthorized type: should return empty slice immediately
	fake.lastFilter = JournalFilter{}
	list, total, err = svc.ListJournals(cashierCtx, JournalFilter{JournalType: "bank_disbursement"})
	if err != nil {
		t.Fatalf("ListJournals error: %v", err)
	}
	if total != 0 || len(list) != 0 {
		t.Errorf("expected 0 journals for unauthorized type, got %d", len(list))
	}

	// 3. GetJournal for cash_receipt: permitted
	j, err := svc.GetJournal(cashierCtx, 10)
	if err != nil {
		t.Fatalf("GetJournal for cash_receipt error: %v", err)
	}
	if j.JournalType != "cash_receipt" {
		t.Errorf("expected cash_receipt, got %s", j.JournalType)
	}

	// 4. GetJournal for bank_disbursement: rejected with ErrNotFound
	fake.journal = db.JournalEntry{ID: 20, JournalType: "bank_disbursement"}
	_, err = svc.GetJournal(cashierCtx, 20)
	if err == nil || !errors.Is(err, ErrNotFound) {
		t.Errorf("expected ErrNotFound for cashier reading bank_disbursement, got %v", err)
	}

	// 5. Sales context requesting ListJournals: AllowedJournalTypes should be deposit types
	salesCtx := auth.ToContext(context.Background(), &auth.User{ID: 2, Role: auth.RoleSales}, 2)
	fake.lastFilter = JournalFilter{}
	_, _, err = svc.ListJournals(salesCtx, JournalFilter{})
	if err != nil {
		t.Fatalf("ListJournals error: %v", err)
	}
	if len(fake.lastFilter.AllowedJournalTypes) != 3 ||
		fake.lastFilter.AllowedJournalTypes[0] != "customer_deposit_receipt" ||
		fake.lastFilter.AllowedJournalTypes[1] != "customer_deposit_usage" ||
		fake.lastFilter.AllowedJournalTypes[2] != "general" {
		t.Errorf("expected sales deposit types in filter, got %v", fake.lastFilter.AllowedJournalTypes)
	}
}

func TestCashDisbursement_ValidationPOSTillAccount(t *testing.T) {
	fake := &rbacStoreFake{}
	svc := NewService(fake, nil, time.UTC)
	// Passing acctCashDefault (1000.01.01) in cash_disbursement lines must return ErrValidation
	lines := []JournalLineInput{
		{AccountCode: "5000.01", Debit: decimal.NewFromInt(50000)},
		{AccountCode: acctCashDefault, Credit: decimal.NewFromInt(50000)},
	}
	_, _, _, err := svc.resolveLines(context.Background(), nil, "cash_disbursement", lines)
	if err == nil || !errors.Is(err, ErrValidation) {
		t.Fatalf("expected ErrValidation when cash_disbursement uses %s, got %v", acctCashDefault, err)
	}
	if !strings.Contains(err.Error(), "akun kas laci POS") {
		t.Errorf("expected error message to mention 'akun kas laci POS', got %v", err)
	}

	// But general journal or cash_receipt with 1000.01.01 should be permitted
	linesGeneral := []JournalLineInput{
		{AccountCode: acctCashDefault, Debit: decimal.NewFromInt(50000)},
		{AccountCode: "4000.01", Credit: decimal.NewFromInt(50000)},
	}
	resolved, _, _, err := svc.resolveLines(context.Background(), nil, "cash_receipt", linesGeneral)
	if err != nil {
		t.Fatalf("expected cash_receipt to allow %s, got error: %v", acctCashDefault, err)
	}
	if len(resolved) != 2 {
		t.Errorf("expected 2 resolved lines, got %d", len(resolved))
	}
}

func TestJournalInput_UnmarshalJSON(t *testing.T) {
	tests := []struct {
		name          string
		jsonStr       string
		expectedStore *int64
	}{
		{
			name:          "numeric store_id",
			jsonStr:       `{"journal_type":"general","store_id":1}`,
			expectedStore: int64Ptr(1),
		},
		{
			name:          "string store_id",
			jsonStr:       `{"journal_type":"general","store_id":"2"}`,
			expectedStore: int64Ptr(2),
		},
		{
			name:          "null store_id",
			jsonStr:       `{"journal_type":"general","store_id":null}`,
			expectedStore: nil,
		},
		{
			name:          "empty string store_id",
			jsonStr:       `{"journal_type":"general","store_id":""}`,
			expectedStore: nil,
		},
		{
			name:          "literal null string store_id",
			jsonStr:       `{"journal_type":"general","store_id":"null"}`,
			expectedStore: nil,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			var in JournalInput
			if err := json.Unmarshal([]byte(tc.jsonStr), &in); err != nil {
				t.Fatalf("unexpected unmarshal error: %v", err)
			}
			if tc.expectedStore == nil {
				if in.StoreID != nil {
					t.Errorf("expected nil StoreID, got %v", *in.StoreID)
				}
			} else {
				if in.StoreID == nil || *in.StoreID != *tc.expectedStore {
					t.Errorf("expected StoreID %v, got %v", *tc.expectedStore, in.StoreID)
				}
			}
		})
	}
}

func int64Ptr(n int64) *int64 {
	return &n
}
