package finance

// Accounting hardening (client feedback 2026-08-26 §5–§10): cash/bank
// accounts, COA counterpart, O/I/F classification, payment vouchers with
// allocation lines, and derived balances. All amounts are computed by the
// backend; the frontend only renders.

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"

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

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

type Hardening struct {
	pool *pgxpool.Pool
	loc  *time.Location
}

func NewHardening(pool *pgxpool.Pool, loc *time.Location) *Hardening {
	return &Hardening{pool: pool, loc: loc}
}

func (h *Hardening) q(ctx context.Context) *db.Queries { return db.New(h.pool) }

func validMethod(m string) bool {
	switch m {
	case "cash", "transfer", "giro", "cheque", "qris", "kartu":
		return true
	}
	return false
}

func validFlowType(f string) bool {
	switch f {
	case "operating", "investing", "financing":
		return true
	}
	return false
}

// ---------- cash / bank accounts ----------

func (h *Hardening) ListCashAccounts(w http.ResponseWriter, r *http.Request) {
	var actFilter *bool
	if v := r.URL.Query().Get("active"); v != "" {
		b := v == "true"
		actFilter = &b
	}
	rows, err := h.q(r.Context()).ListCashAccounts(r.Context(), pgBoolPtr(actFilter))
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), rows)
}

func (h *Hardening) CreateCashAccount(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Name              string  `json:"name"`
		AccountType       string  `json:"account_type"`
		BankName          *string `json:"bank_name"`
		BankAccountNumber *string `json:"bank_account_number"`
	}
	if err := decodeHard(w, r, &in); err != nil {
		return
	}
	if in.Name == "" || (in.AccountType != "cash" && in.AccountType != "bank") {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, "name and account_type (cash|bank) are required", nil)
		return
	}
	u := auth.UserFrom(r.Context())
	var createdBy pgtype.Int8
	if u != nil {
		createdBy = pgtype.Int8{Int64: u.ID, Valid: true}
	}
	row, err := h.q(r.Context()).CreateCashAccount(r.Context(), db.CreateCashAccountParams{
		Name: in.Name, AccountType: in.AccountType,
		BankName: textPgPtr(in.BankName), BankAccountNumber: textPgPtr(in.BankAccountNumber),
		CreatedBy: createdBy,
	})
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusCreated, middleware.RequestIDFrom(r.Context()), row)
}

// ---------- chart of accounts ----------

func (h *Hardening) ListAccounts(w http.ResponseWriter, r *http.Request) {
	var actFilter *bool
	if v := r.URL.Query().Get("active"); v != "" {
		b := v == "true"
		actFilter = &b
	}
	rows, err := h.q(r.Context()).ListAccounts(r.Context(), pgBoolPtr(actFilter))
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), rows)
}

// ---------- balances ----------

// Balances is READ PATH 3 of 5 (GET /cashflow-balances).
//
// SCOPED, and this one is the easiest of the five to get wrong because it
// returns no rows at all — only four SUMS. An unscoped version answers a till
// operator's question with the COMPANY's opening balance and period movement
// while never showing a cashflow line, so scoping only the list would look like
// it worked. See migration 000104 §4.
//
// The scope comes from the same resolver as the list, so the two cannot drift.
func (h *Hardening) Balances(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	storeIDs, err := cashflowStoreScope(ctx)
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	q := r.URL.Query()
	accountID := int64(0)
	if v := q.Get("account_id"); v != "" {
		accountID = queryInt64Default(v, 0)
	}
	dateFrom := q.Get("date_from")
	if dateFrom == "" {
		dateFrom = "1970-01-01"
	}
	totals, err := h.q(ctx).AccountMovementTotals(ctx, db.AccountMovementTotalsParams{
		DateFrom:  dateOrNil(dateFrom),
		DateTo:    pgDateOrNilStr(q.Get("date_to")),
		AccountID: accountID,
		StoreIds:  storeIDs,
	})
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	opening := totals.OpeningIn.Sub(totals.OpeningOut)
	current := opening.Add(totals.PeriodIn).Sub(totals.PeriodOut)
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]string{
		"account_id": q.Get("account_id"),
		"date_from":  dateFrom,
		"date_to":    q.Get("date_to"),
		"opening":    opening.StringFixed(2),
		"cash_in":    totals.PeriodIn.StringFixed(2),
		"cash_out":   totals.PeriodOut.StringFixed(2),
		"current":    current.StringFixed(2),
	})
}

// AccountBalances is ROUTE 4 of the 5 the cashflow brief lists, and it is
// deliberately NOT given cashflow outlet scoping, because it reads NO cashflow
// rows. AccountBalancesByCode aggregates journal_lines joined to posted or
// reversed journal_entries; the only mention of cashflows is in the query's
// doc comment, describing why cash accounts (1xxx) read from journal lines only.
// Adding a store_id predicate here would be cargo-culting: there is no cashflow
// column for it to filter, and a predicate that matches nothing is worse than
// no predicate because it reads like a control that exists.
//
// IT IS STILL A COMPANY-WIDE FIGURE, THOUGH, and that is a finding rather than a
// fix. A restricted caller with ?codes= gets all-time company account balances.
// Closing that needs a store dimension on journal_entries/journal_lines, which
// is a different schema change from migration 000104 and outside this brief.
// Reported, not silently widened and not silently ignored.
func (h *Hardening) AccountBalances(w http.ResponseWriter, r *http.Request) {
	raw := r.URL.Query().Get("codes")
	if raw == "" {
		httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), []any{})
		return
	}
	codes := strings.Split(raw, ",")
	for i := range codes {
		codes[i] = strings.TrimSpace(codes[i])
	}
	rows, err := h.q(r.Context()).AccountBalancesByCode(r.Context(), codes)
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	out := make([]map[string]string, 0, len(rows))
	for _, row := range rows {
		debit := row.Debit
		credit := row.Credit
		bal := debit.Sub(credit)
		if row.Debit.IsZero() && row.Credit.IsZero() {
			bal = decimal.Zero
		}
		out = append(out, map[string]string{
			"code":    row.Code,
			"name":    row.Name,
			"debit":   row.Debit.StringFixed(2),
			"credit":  row.Credit.StringFixed(2),
			"balance": bal.StringFixed(2),
		})
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), out)
}

// ---------- payment vouchers ----------

type PVLineInput struct {
	AccountID   int64           `json:"account_id"`
	Description *string         `json:"description"`
	Amount      decimal.Decimal `json:"amount"`
	Reference   *string         `json:"reference"`
}

type PaymentVoucherInput struct {
	VoucherDate   string        `json:"voucher_date"`
	AccountID     int64         `json:"account_id"`
	Payee         string        `json:"payee"`
	PaymentMethod string        `json:"payment_method"`
	Reference     *string       `json:"reference"`
	Description   *string       `json:"description"`
	Lines         []PVLineInput `json:"lines"`
}

func (h *Hardening) CreatePaymentVoucher(w http.ResponseWriter, r *http.Request) {
	u := auth.UserFrom(r.Context())
	var in PaymentVoucherInput
	if err := decodeHard(w, r, &in); err != nil {
		return
	}
	if in.Payee == "" || in.VoucherDate == "" {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, "payee and voucher_date are required", nil)
		return
	}
	if !validMethod(in.PaymentMethod) {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, ErrPaymentMethodInvalid.Error(), nil)
		return
	}
	if len(in.Lines) == 0 || in.AccountID <= 0 {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, "account_id and at least one allocation line are required", nil)
		return
	}
	ctx := r.Context()
	tx, err := h.pool.Begin(ctx)
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	defer tx.Rollback(ctx)

	vdate, perr := time.Parse("2006-01-02", in.VoucherDate)
	if perr != nil {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, "voucher_date must be YYYY-MM-DD", nil)
		return
	}
	n, serr := seq.Next(ctx, tx, seqKeyVPC, seq.MonthStart(vdate))
	if serr != nil {
		respondFinanceErr(w, r, serr)
		return
	}
	number := fmt.Sprintf("VPC-%s-%04d", vdate.Format("200601"), n)

	var createdBy pgtype.Int8
	if u != nil {
		createdBy = pgtype.Int8{Int64: u.ID, Valid: true}
	}
	total := decimalZero()
	vrow, err := h.q(ctx).CreatePaymentVoucher(ctx, db.CreatePaymentVoucherParams{
		VoucherNumber: number,
		VoucherDate:   pgDate(vdate),
		AccountID:     in.AccountID,
		Payee:         in.Payee,
		PaymentMethod: in.PaymentMethod,
		Reference:     textPgPtr(in.Reference),
		Description:   textPgPtr(in.Description),
		TotalAmount:   total,
		CreatedBy:     createdBy,
	})
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	for _, ln := range in.Lines {
		if ln.Amount.Sign() <= 0 || ln.AccountID <= 0 {
			httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
				httpresponse.CodeValidation, "allocation lines need account_id and positive amount", nil)
			return
		}
		if _, err := h.q(ctx).CreatePaymentVoucherLine(ctx, db.CreatePaymentVoucherLineParams{
			VoucherID:   vrow.ID,
			AccountID:   ln.AccountID,
			Description: textPgPtr(ln.Description),
			Amount:      ln.Amount,
			Reference:   textPgPtr(ln.Reference),
		}); err != nil {
			respondFinanceErr(w, r, err)
			return
		}
		total = total.Add(ln.Amount)
	}
	if _, err := h.q(ctx).ExecUpdateVoucherTotal(ctx, db.ExecUpdateVoucherTotalParams{
		ID: vrow.ID, TotalAmount: total,
	}); err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	if err := tx.Commit(ctx); err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusCreated, middleware.RequestIDFrom(r.Context()), map[string]any{
		"id": vrow.ID, "voucher_number": number, "total_amount": total.StringFixed(2), "status": "draft",
	})
}

// Post approves the voucher and flips draft -> posted. Arus Kas entries are
// manual, so posting generates no cashflow rows.
func (h *Hardening) PostPaymentVoucher(w http.ResponseWriter, r *http.Request) {
	id := pathInt64(r)
	u := auth.UserFrom(r.Context())
	ctx := r.Context()
	v, err := h.q(ctx).GetPaymentVoucherForUpdate(ctx, id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			respondNotFound(w, r)
			return
		}
		respondFinanceErr(w, r, err)
		return
	}
	if v.Status != "draft" {
		httpresponse.Error(w, http.StatusConflict, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeForbidden, "only draft vouchers can be posted", nil)
		return
	}
	var approver pgtype.Int8
	if u != nil {
		approver = pgtype.Int8{Int64: u.ID, Valid: true}
	}
	if _, err := h.q(ctx).ExecSetPVStatus(ctx, db.ExecSetPVStatusParams{
		ID: id, Status: "posted", ApprovedBy: approver,
	}); err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{"id": id, "status": "posted"})
}

// Void flips posted -> voided; Arus Kas entries are manual, so no reversal
// rows are generated here.
func (h *Hardening) VoidPaymentVoucher(w http.ResponseWriter, r *http.Request) {
	id := pathInt64(r)
	ctx := r.Context()
	v, err := h.q(ctx).GetPaymentVoucherForUpdate(ctx, id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			respondNotFound(w, r)
			return
		}
		respondFinanceErr(w, r, err)
		return
	}
	if v.Status != "posted" {
		httpresponse.Error(w, http.StatusConflict, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeForbidden, "only posted vouchers can be voided", nil)
		return
	}
	if _, err := h.q(ctx).ExecSetPVStatus(ctx, db.ExecSetPVStatusParams{ID: id, Status: "voided"}); err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{"id": id, "status": "voided"})
}

func (h *Hardening) GetPaymentVoucher(w http.ResponseWriter, r *http.Request) {
	id := pathInt64(r)
	v, err := h.q(r.Context()).GetPaymentVoucher(r.Context(), id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			respondNotFound(w, r)
			return
		}
		respondFinanceErr(w, r, err)
		return
	}
	lines, err := h.q(r.Context()).ListPaymentVoucherLines(r.Context(), id)
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{"voucher": v, "lines": lines})
}

func (h *Hardening) ListPaymentVouchers(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	page := queryInt64Default(q.Get("page"), 1)
	perPage := queryInt64Default(q.Get("per_page"), 20)
	status := pgTextV(q.Get("status"))
	offset := (page - 1) * perPage
	rows, err := h.q(r.Context()).ListPaymentVouchers(r.Context(), db.ListPaymentVouchersParams{
		Status: status, Limit: int32(perPage), Offset: int32(offset),
	})
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	total, err := h.q(r.Context()).CountPaymentVouchers(r.Context(), status)
	if err != nil {
		respondFinanceErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()),
		map[string]any{"items": rows, "total": total, "page": page, "per_page": perPage})
}
