package models

import (
	"fmt"
	"time"

	"odoo-go/pkg/orm"
)

// initAccountJournal registers account.journal — where entries are posted.
// Mirrors: odoo/addons/account/models/account_journal.py
func initAccountJournal() {
	m := orm.NewModel("account.journal", orm.ModelOpts{
		Description: "Journal",
		Order:       "sequence, type, code",
	})

	m.AddFields(
		orm.Char("name", orm.FieldOpts{String: "Journal Name", Required: true, Translate: true}),
		orm.Char("code", orm.FieldOpts{String: "Short Code", Required: true, Size: 5}),
		orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
		orm.Selection("type", []orm.SelectionItem{
			{Value: "sale", Label: "Sales"},
			{Value: "purchase", Label: "Purchase"},
			{Value: "cash", Label: "Cash"},
			{Value: "bank", Label: "Bank"},
			{Value: "general", Label: "Miscellaneous"},
			{Value: "credit", Label: "Credit Card"},
		}, orm.FieldOpts{String: "Type", Required: true}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
		orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
		orm.Many2one("default_account_id", "account.account", orm.FieldOpts{String: "Default Account"}),
		orm.Many2one("suspense_account_id", "account.account", orm.FieldOpts{String: "Suspense Account"}),
		orm.Many2one("profit_account_id", "account.account", orm.FieldOpts{String: "Profit Account"}),
		orm.Many2one("loss_account_id", "account.account", orm.FieldOpts{String: "Loss Account"}),
		orm.Many2one("sequence_id", "ir.sequence", orm.FieldOpts{String: "Entry Sequence"}),
		orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
		orm.Integer("color", orm.FieldOpts{String: "Color"}),
		orm.Char("bank_acc_number", orm.FieldOpts{String: "Account Number"}),
		orm.Many2one("bank_id", "res.bank", orm.FieldOpts{String: "Bank"}),
		orm.Many2one("bank_account_id", "res.partner.bank", orm.FieldOpts{String: "Bank Account"}),
		orm.Boolean("restrict_mode_hash_table", orm.FieldOpts{String: "Lock Posted Entries with Hash"}),
	)
}

// initAccountMove registers account.move — the core journal entry / invoice model.
// Mirrors: odoo/addons/account/models/account_move.py
//
// account.move is THE central model in Odoo accounting. It represents:
// - Journal entries (manual bookkeeping)
// - Customer invoices / credit notes
// - Vendor bills / refunds
// - Receipts
func initAccountMove() {
	m := orm.NewModel("account.move", orm.ModelOpts{
		Description: "Journal Entry",
		Order:       "date desc, name desc, id desc",
		RecName:     "name",
	})

	// -- Identity --
	m.AddFields(
		orm.Char("name", orm.FieldOpts{String: "Number", Index: true, Readonly: true, Default: "/"}),
		orm.Date("date", orm.FieldOpts{String: "Date", Required: true, Index: true}),
		orm.Char("ref", orm.FieldOpts{String: "Reference"}),
	)

	// -- Type & State --
	m.AddFields(
		orm.Selection("move_type", []orm.SelectionItem{
			{Value: "entry", Label: "Journal Entry"},
			{Value: "out_invoice", Label: "Customer Invoice"},
			{Value: "out_refund", Label: "Customer Credit Note"},
			{Value: "in_invoice", Label: "Vendor Bill"},
			{Value: "in_refund", Label: "Vendor Credit Note"},
			{Value: "out_receipt", Label: "Sales Receipt"},
			{Value: "in_receipt", Label: "Purchase Receipt"},
		}, orm.FieldOpts{String: "Type", Required: true, Default: "entry", Index: true}),
		orm.Selection("state", []orm.SelectionItem{
			{Value: "draft", Label: "Draft"},
			{Value: "posted", Label: "Posted"},
			{Value: "cancel", Label: "Cancelled"},
		}, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}),
		orm.Selection("payment_state", []orm.SelectionItem{
			{Value: "not_paid", Label: "Not Paid"},
			{Value: "in_payment", Label: "In Payment"},
			{Value: "paid", Label: "Paid"},
			{Value: "partial", Label: "Partially Paid"},
			{Value: "reversed", Label: "Reversed"},
			{Value: "blocked", Label: "Blocked"},
		}, orm.FieldOpts{String: "Payment Status", Compute: "_compute_payment_state", Store: true}),
	)

	// -- Relationships --
	m.AddFields(
		orm.Many2one("journal_id", "account.journal", orm.FieldOpts{
			String: "Journal", Required: true, Index: true,
		}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{
			String: "Company", Required: true, Index: true,
		}),
		orm.Many2one("currency_id", "res.currency", orm.FieldOpts{
			String: "Currency", Required: true,
		}),
		orm.Many2one("partner_id", "res.partner", orm.FieldOpts{
			String: "Partner", Index: true,
		}),
		orm.Many2one("commercial_partner_id", "res.partner", orm.FieldOpts{
			String: "Commercial Entity", Related: "partner_id.commercial_partner_id",
		}),
		orm.Many2one("fiscal_position_id", "account.fiscal.position", orm.FieldOpts{
			String: "Fiscal Position",
		}),
		orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{
			String: "Recipient Bank",
		}),
	)

	// -- Lines --
	m.AddFields(
		orm.One2many("line_ids", "account.move.line", "move_id", orm.FieldOpts{
			String: "Journal Items",
		}),
		orm.One2many("invoice_line_ids", "account.move.line", "move_id", orm.FieldOpts{
			String: "Invoice Lines",
		}),
	)

	// -- Multi-Currency --
	m.AddFields(
		orm.Many2one("company_currency_id", "res.currency", orm.FieldOpts{
			String: "Company Currency", Related: "company_id.currency_id",
		}),
	)

	// -- Amounts (Computed) --
	m.AddFields(
		orm.Monetary("amount_untaxed", orm.FieldOpts{String: "Untaxed Amount", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
		orm.Monetary("amount_tax", orm.FieldOpts{String: "Tax", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
		orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
		orm.Monetary("amount_residual", orm.FieldOpts{String: "Amount Due", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}),
		orm.Monetary("amount_total_in_currency_signed", orm.FieldOpts{String: "Total in Currency Signed", Compute: "_compute_amount", Store: true}),
		orm.Monetary("amount_total_signed", orm.FieldOpts{String: "Total Signed", Compute: "_compute_amount", Store: true, CurrencyField: "company_currency_id"}),
	)

	// -- Invoice specific --
	m.AddFields(
		orm.Date("invoice_date", orm.FieldOpts{String: "Invoice/Bill Date"}),
		orm.Date("invoice_date_due", orm.FieldOpts{String: "Due Date"}),
		orm.Char("invoice_origin", orm.FieldOpts{String: "Source Document"}),
		orm.Many2one("invoice_payment_term_id", "account.payment.term", orm.FieldOpts{
			String: "Payment Terms",
		}),
		orm.Text("narration", orm.FieldOpts{String: "Terms and Conditions"}),
	)

	// -- Invoice Responsible & References --
	m.AddFields(
		orm.Many2one("invoice_user_id", "res.users", orm.FieldOpts{
			String: "Salesperson", Help: "User responsible for this invoice",
		}),
		orm.Many2one("reversed_entry_id", "account.move", orm.FieldOpts{
			String: "Reversed Entry", Help: "The move that was reversed to create this",
		}),
		orm.Char("access_url", orm.FieldOpts{String: "Portal Access URL", Compute: "_compute_access_url"}),
		orm.Boolean("invoice_has_outstanding", orm.FieldOpts{
			String: "Has Outstanding Payments", Compute: "_compute_invoice_has_outstanding",
		}),
	)

	// -- Technical --
	m.AddFields(
		orm.Boolean("auto_post", orm.FieldOpts{String: "Auto-post"}),
		orm.Char("sequence_prefix", orm.FieldOpts{String: "Sequence Prefix"}),
		orm.Integer("sequence_number", orm.FieldOpts{String: "Sequence Number"}),
	)

	// _compute_access_url: generates /my/invoices/<id> for portal access.
	// Mirrors: odoo/addons/account/models/account_move.py _compute_access_url()
	m.RegisterCompute("access_url", func(rs *orm.Recordset) (orm.Values, error) {
		moveID := rs.IDs()[0]
		return orm.Values{
			"access_url": fmt.Sprintf("/my/invoices/%d", moveID),
		}, nil
	})

	// _compute_invoice_has_outstanding: checks for outstanding payments.
	// Mirrors: odoo/addons/account/models/account_move.py _compute_has_outstanding()
	m.RegisterCompute("invoice_has_outstanding", func(rs *orm.Recordset) (orm.Values, error) {
		env := rs.Env()
		moveID := rs.IDs()[0]

		var partnerID *int64
		var moveType string
		env.Tx().QueryRow(env.Ctx(),
			`SELECT partner_id, COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID,
		).Scan(&partnerID, &moveType)

		hasOutstanding := false
		if partnerID != nil && *partnerID > 0 && (moveType == "out_invoice" || moveType == "out_refund") {
			var count int
			env.Tx().QueryRow(env.Ctx(),
				`SELECT COUNT(*) FROM account_move_line l
				 JOIN account_account a ON a.id = l.account_id
				 JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
				 WHERE l.partner_id = $1 AND a.account_type = 'asset_receivable'
				 AND l.amount_residual < -0.005 AND l.reconciled = false`,
				*partnerID).Scan(&count)
			hasOutstanding = count > 0
		}

		return orm.Values{
			"invoice_has_outstanding": hasOutstanding,
		}, nil
	})

	// -- Computed Fields --
	// _compute_amount: sums invoice lines to produce totals.
	// Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount()
	//
	// Separates untaxed (product lines) from tax (tax lines) via display_type,
	// then derives total = untaxed + tax.
	computeAmount := func(rs *orm.Recordset) (orm.Values, error) {
		env := rs.Env()
		moveID := rs.IDs()[0]

		var untaxed, tax float64
		err := env.Tx().QueryRow(env.Ctx(),
			`SELECT
				COALESCE(SUM(CASE WHEN display_type IS NULL OR display_type = '' OR display_type = 'product' THEN ABS(balance) ELSE 0 END), 0),
				COALESCE(SUM(CASE WHEN display_type = 'tax' THEN ABS(balance) ELSE 0 END), 0)
			 FROM account_move_line WHERE move_id = $1`, moveID,
		).Scan(&untaxed, &tax)
		if err != nil {
			return nil, err
		}

		total := untaxed + tax

		// amount_total_signed: total in company currency (sign depends on move type)
		// For customer invoices/receipts the sign is positive, for credit notes negative.
		var moveType string
		env.Tx().QueryRow(env.Ctx(),
			`SELECT COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID,
		).Scan(&moveType)

		sign := 1.0
		if moveType == "out_refund" || moveType == "in_refund" {
			sign = -1.0
		}

		return orm.Values{
			"amount_untaxed":     untaxed,
			"amount_tax":         tax,
			"amount_total":       total,
			"amount_residual":    total, // Simplified: residual = total until payments
			"amount_total_signed": total * sign,
		}, nil
	}

	m.RegisterCompute("amount_untaxed", computeAmount)
	m.RegisterCompute("amount_tax", computeAmount)
	m.RegisterCompute("amount_total", computeAmount)
	m.RegisterCompute("amount_residual", computeAmount)
	m.RegisterCompute("amount_total_signed", computeAmount)

	// -- Business Methods: State Transitions --
	// Mirrors: odoo/addons/account/models/account_move.py action_post(), button_cancel()

	// action_post: draft → posted
	m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, id := range rs.IDs() {
			var state string
			err := env.Tx().QueryRow(env.Ctx(),
				`SELECT state FROM account_move WHERE id = $1`, id).Scan(&state)
			if err != nil {
				return nil, err
			}
			if state != "draft" {
				return nil, fmt.Errorf("account: can only post draft entries (current: %s)", state)
			}

			// Check lock dates
			// Mirrors: odoo/addons/account/models/account_move.py _check_fiscalyear_lock_date()
			var moveDate, periodLock, fiscalLock, taxLock *string
			env.Tx().QueryRow(env.Ctx(),
				`SELECT m.date::text, c.period_lock_date::text, c.fiscalyear_lock_date::text, c.tax_lock_date::text
				 FROM account_move m JOIN res_company c ON c.id = m.company_id WHERE m.id = $1`, id,
			).Scan(&moveDate, &periodLock, &fiscalLock, &taxLock)

			if fiscalLock != nil && moveDate != nil && *moveDate <= *fiscalLock {
				return nil, fmt.Errorf("account: cannot post entry dated %s, fiscal year is locked until %s", *moveDate, *fiscalLock)
			}
			if periodLock != nil && moveDate != nil && *moveDate <= *periodLock {
				return nil, fmt.Errorf("account: cannot post entry dated %s, period is locked until %s", *moveDate, *periodLock)
			}
			if taxLock != nil && moveDate != nil && *moveDate <= *taxLock {
				return nil, fmt.Errorf("account: cannot post entry dated %s, tax return is locked until %s", *moveDate, *taxLock)
			}

			// Check partner is set for invoice types
			var moveType string
			env.Tx().QueryRow(env.Ctx(), `SELECT move_type FROM account_move WHERE id = $1`, id).Scan(&moveType)
			if moveType != "entry" {
				var partnerID *int64
				env.Tx().QueryRow(env.Ctx(), `SELECT partner_id FROM account_move WHERE id = $1`, id).Scan(&partnerID)
				if partnerID == nil || *partnerID == 0 {
					return nil, fmt.Errorf("account: invoice requires a partner")
				}
			}

			// Check at least one line exists
			var lineCount int
			env.Tx().QueryRow(env.Ctx(), `SELECT count(*) FROM account_move_line WHERE move_id = $1`, id).Scan(&lineCount)
			if lineCount == 0 {
				return nil, fmt.Errorf("account: cannot post an entry with no lines")
			}

			// Check balanced
			var debitSum, creditSum float64
			env.Tx().QueryRow(env.Ctx(),
				`SELECT COALESCE(SUM(debit),0), COALESCE(SUM(credit),0)
				 FROM account_move_line WHERE move_id = $1`, id).Scan(&debitSum, &creditSum)
			diff := debitSum - creditSum
			if diff < -0.005 || diff > 0.005 {
				return nil, fmt.Errorf("account: cannot post unbalanced entry (debit=%.2f, credit=%.2f)", debitSum, creditSum)
			}

			// Assign sequence number if name is still "/"
			var name string
			env.Tx().QueryRow(env.Ctx(), `SELECT name FROM account_move WHERE id = $1`, id).Scan(&name)
			if name == "/" || name == "" {
				// Generate sequence number
				var journalID int64
				var journalCode string
				env.Tx().QueryRow(env.Ctx(),
					`SELECT j.id, j.code FROM account_journal j
					 JOIN account_move m ON m.journal_id = j.id WHERE m.id = $1`, id,
				).Scan(&journalID, &journalCode)

				// Get next sequence number
				var nextNum int64
				env.Tx().QueryRow(env.Ctx(),
					`SELECT COALESCE(MAX(sequence_number), 0) + 1 FROM account_move WHERE journal_id = $1`,
					journalID).Scan(&nextNum)

				// Format: journalCode/YYYY/NNNN
				year := time.Now().Format("2006")
				newName := fmt.Sprintf("%s/%s/%04d", journalCode, year, nextNum)

				env.Tx().Exec(env.Ctx(),
					`UPDATE account_move SET name = $1, sequence_number = $2 WHERE id = $3`,
					newName, nextNum, id)
			}

			if _, err := env.Tx().Exec(env.Ctx(),
				`UPDATE account_move SET state = 'posted' WHERE id = $1`, id); err != nil {
				return nil, err
			}
		}
		return true, nil
	})

	// button_cancel: posted → cancel (or draft → cancel)
	m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, id := range rs.IDs() {
			if _, err := env.Tx().Exec(env.Ctx(),
				`UPDATE account_move SET state = 'cancel' WHERE id = $1`, id); err != nil {
				return nil, err
			}
		}
		return true, nil
	})

	// button_draft: cancel → draft
	m.RegisterMethod("button_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, id := range rs.IDs() {
			var state string
			err := env.Tx().QueryRow(env.Ctx(),
				`SELECT state FROM account_move WHERE id = $1`, id).Scan(&state)
			if err != nil {
				return nil, err
			}
			if state != "cancel" {
				return nil, fmt.Errorf("account: can only reset cancelled entries to draft (current: %s)", state)
			}
			if _, err := env.Tx().Exec(env.Ctx(),
				`UPDATE account_move SET state = 'draft' WHERE id = $1`, id); err != nil {
				return nil, err
			}
		}
		return true, nil
	})

	// action_reverse: creates a credit note (reversal) for the current move.
	// Mirrors: odoo/addons/account/models/account_move.py action_reverse()
	m.RegisterMethod("action_reverse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, moveID := range rs.IDs() {
			// Read original move
			var partnerID, journalID, companyID, currencyID int64
			var moveType string
			err := env.Tx().QueryRow(env.Ctx(),
				`SELECT COALESCE(partner_id,0), COALESCE(journal_id,0), COALESCE(company_id,0),
				 COALESCE(currency_id,0), COALESCE(move_type,'entry')
				 FROM account_move WHERE id = $1`, moveID,
			).Scan(&partnerID, &journalID, &companyID, &currencyID, &moveType)
			if err != nil {
				return nil, fmt.Errorf("account: read move %d for reversal: %w", moveID, err)
			}

			// Determine reverse type
			reverseType := moveType
			switch moveType {
			case "out_invoice":
				reverseType = "out_refund"
			case "in_invoice":
				reverseType = "in_refund"
			case "out_refund":
				reverseType = "out_invoice"
			case "in_refund":
				reverseType = "in_invoice"
			}

			// Create reverse move
			reverseRS := env.Model("account.move")
			reverseMoveVals := orm.Values{
				"move_type":        reverseType,
				"partner_id":       partnerID,
				"journal_id":       journalID,
				"company_id":       companyID,
				"currency_id":      currencyID,
				"reversed_entry_id": moveID,
				"ref":              fmt.Sprintf("Reversal of %d", moveID),
			}
			reverseMove, err := reverseRS.Create(reverseMoveVals)
			if err != nil {
				return nil, fmt.Errorf("account: create reverse move: %w", err)
			}

			// Copy lines with reversed debit/credit
			lineRows, err := env.Tx().Query(env.Ctx(),
				`SELECT account_id, name, COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
				        COALESCE(balance::float8, 0), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0),
				        COALESCE(display_type, 'product'), partner_id, currency_id
				 FROM account_move_line WHERE move_id = $1`, moveID)
			if err != nil {
				return nil, fmt.Errorf("account: read lines for reversal: %w", err)
			}

			lineRS := env.Model("account.move.line")
			var reverseLines []orm.Values
			for lineRows.Next() {
				var accID int64
				var name string
				var debit, credit, balance, qty, price float64
				var displayType string
				var lpID, lcurID *int64
				if err := lineRows.Scan(&accID, &name, &debit, &credit, &balance, &qty, &price, &displayType, &lpID, &lcurID); err != nil {
					lineRows.Close()
					return nil, fmt.Errorf("account: scan line for reversal: %w", err)
				}

				lineVals := orm.Values{
					"move_id":      reverseMove.ID(),
					"account_id":   accID,
					"name":         name,
					"debit":        credit,   // REVERSED
					"credit":       debit,    // REVERSED
					"balance":      -balance,  // REVERSED
					"quantity":     qty,
					"price_unit":   price,
					"display_type": displayType,
					"company_id":   companyID,
					"journal_id":   journalID,
				}
				if lpID != nil {
					lineVals["partner_id"] = *lpID
				}
				if lcurID != nil {
					lineVals["currency_id"] = *lcurID
				}
				reverseLines = append(reverseLines, lineVals)
			}
			lineRows.Close()

			for _, lv := range reverseLines {
				if _, err := lineRS.Create(lv); err != nil {
					return nil, fmt.Errorf("account: create reverse line: %w", err)
				}
			}

			return map[string]interface{}{
				"type":      "ir.actions.act_window",
				"res_model": "account.move",
				"res_id":    reverseMove.ID(),
				"view_mode": "form",
				"views":     [][]interface{}{{nil, "form"}},
				"target":    "current",
			}, nil
		}
		return false, nil
	})

	// action_register_payment: opens the payment register wizard.
	// Mirrors: odoo/addons/account/models/account_move.py action_register_payment()
	m.RegisterMethod("action_register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		return map[string]interface{}{
			"type":      "ir.actions.act_window",
			"name":      "Register Payment",
			"res_model": "account.payment.register",
			"view_mode": "form",
			"views":     [][]interface{}{{nil, "form"}},
			"target":    "new",
			"context": map[string]interface{}{
				"active_model": "account.move",
				"active_ids":   rs.IDs(),
			},
		}, nil
	})

	// -- Business Method: create_invoice_with_tax --
	// Creates a customer invoice with automatic tax line generation.
	// For each product line that carries a tax_id, a separate tax line
	// (display_type='tax') is created. A receivable line balances the entry.
	//
	// args[0]: partner_id (int64 or float64)
	// args[1]: lines ([]interface{} of map[string]interface{})
	//          Each line: {name, quantity, price_unit, account_id, tax_id?}
	//
	// Returns: the created account.move ID (int64)
	m.RegisterMethod("create_invoice_with_tax", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		if len(args) < 2 {
			return nil, fmt.Errorf("account: create_invoice_with_tax requires partner_id and lines")
		}

		env := rs.Env()

		partnerID, ok := toInt64Arg(args[0])
		if !ok {
			return nil, fmt.Errorf("account: invalid partner_id")
		}

		rawLines, ok := args[1].([]interface{})
		if !ok {
			return nil, fmt.Errorf("account: lines must be a list")
		}

		// Step 1: Create the move header (draft invoice)
		moveRS := env.Model("account.move")
		moveVals := orm.Values{
			"move_type":  "out_invoice",
			"partner_id": partnerID,
		}
		move, err := moveRS.Create(moveVals)
		if err != nil {
			return nil, fmt.Errorf("account: create move: %w", err)
		}
		moveID := move.ID()

		// Retrieve company_id, journal_id, currency_id from the created move
		moveData, err := move.Read([]string{"company_id", "journal_id", "currency_id"})
		if err != nil || len(moveData) == 0 {
			return nil, fmt.Errorf("account: cannot read created move")
		}
		companyID, _ := toInt64Arg(moveData[0]["company_id"])
		journalID, _ := toInt64Arg(moveData[0]["journal_id"])
		currencyID, _ := toInt64Arg(moveData[0]["currency_id"])

		// Find the receivable account for the partner
		var receivableAccountID int64
		env.Tx().QueryRow(env.Ctx(),
			`SELECT id FROM account_account
			 WHERE account_type = 'asset_receivable' AND company_id = $1
			 ORDER BY code LIMIT 1`, companyID,
		).Scan(&receivableAccountID)
		if receivableAccountID == 0 {
			return nil, fmt.Errorf("account: no receivable account found for company %d", companyID)
		}

		lineRS := env.Model("account.move.line")

		var totalDebit float64 // sum of all product + tax debits

		// Step 2: For each input line, create product line(s) and tax line(s)
		for _, rawLine := range rawLines {
			lineMap, ok := rawLine.(map[string]interface{})
			if !ok {
				continue
			}

			name, _ := lineMap["name"].(string)
			quantity := floatArg(lineMap["quantity"], 1.0)
			priceUnit := floatArg(lineMap["price_unit"], 0.0)
			accountID, _ := toInt64Arg(lineMap["account_id"])
			taxID, hasTax := toInt64Arg(lineMap["tax_id"])

			if accountID == 0 {
				// Fallback: use journal default account
				env.Tx().QueryRow(env.Ctx(),
					`SELECT default_account_id FROM account_journal WHERE id = $1`, journalID,
				).Scan(&accountID)
			}
			if accountID == 0 {
				return nil, fmt.Errorf("account: no account_id for line %q", name)
			}

			baseAmount := priceUnit * quantity

			// Create product line (debit side for revenue)
			productLineVals := orm.Values{
				"move_id":      moveID,
				"name":         name,
				"quantity":     quantity,
				"price_unit":   priceUnit,
				"account_id":   accountID,
				"company_id":   companyID,
				"journal_id":   journalID,
				"currency_id":  currencyID,
				"partner_id":   partnerID,
				"display_type": "product",
				"debit":        0.0,
				"credit":       baseAmount,
				"balance":      -baseAmount,
			}
			if _, err := lineRS.Create(productLineVals); err != nil {
				return nil, fmt.Errorf("account: create product line: %w", err)
			}
			totalDebit += baseAmount

			// If a tax is specified, compute and create the tax line
			if hasTax && taxID > 0 {
				taxResult, err := ComputeTax(env, taxID, baseAmount)
				if err != nil {
					return nil, fmt.Errorf("account: compute tax: %w", err)
				}

				if taxResult.Amount != 0 && taxResult.AccountID != 0 {
					taxLineVals := orm.Values{
						"move_id":      moveID,
						"name":         taxResult.TaxName,
						"quantity":     1.0,
						"account_id":   taxResult.AccountID,
						"company_id":   companyID,
						"journal_id":   journalID,
						"currency_id":  currencyID,
						"partner_id":   partnerID,
						"display_type": "tax",
						"tax_line_id":  taxResult.TaxID,
						"debit":        0.0,
						"credit":       taxResult.Amount,
						"balance":      -taxResult.Amount,
					}
					if _, err := lineRS.Create(taxLineVals); err != nil {
						return nil, fmt.Errorf("account: create tax line: %w", err)
					}
					totalDebit += taxResult.Amount
				}
			}
		}

		// Step 3: Create the receivable line (debit = total of all credits)
		receivableVals := orm.Values{
			"move_id":          moveID,
			"name":             "/",
			"quantity":         1.0,
			"account_id":       receivableAccountID,
			"company_id":       companyID,
			"journal_id":       journalID,
			"currency_id":      currencyID,
			"partner_id":       partnerID,
			"display_type":     "payment_term",
			"debit":            totalDebit,
			"credit":           0.0,
			"balance":          totalDebit,
			"amount_residual":  totalDebit,
		}
		if _, err := lineRS.Create(receivableVals); err != nil {
			return nil, fmt.Errorf("account: create receivable line: %w", err)
		}

		return moveID, nil
	})

	// -- Double-Entry Constraint --
	// SUM(debit) must equal SUM(credit) per journal entry.
	// Mirrors: odoo/addons/account/models/account_move.py _check_balanced()
	m.AddConstraint(func(rs *orm.Recordset) error {
		env := rs.Env()
		for _, id := range rs.IDs() {
			var debitSum, creditSum float64
			err := env.Tx().QueryRow(env.Ctx(),
				`SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
				 FROM account_move_line WHERE move_id = $1`, id,
			).Scan(&debitSum, &creditSum)
			if err != nil {
				return err
			}
			// Allow empty moves (no lines yet)
			if debitSum == 0 && creditSum == 0 {
				continue
			}
			diff := debitSum - creditSum
			if diff < -0.005 || diff > 0.005 {
				return fmt.Errorf("account: journal entry is unbalanced — debit=%.2f credit=%.2f (diff=%.2f)", debitSum, creditSum, diff)
			}
		}
		return nil
	})

	// -- DefaultGet: Provide dynamic defaults for new records --
	// Mirrors: odoo/addons/account/models/account_move.py AccountMove.default_get()
	// Supplies date, journal_id, company_id, currency_id when creating a new invoice.
	m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
		vals := make(orm.Values)

		// Default date = today
		vals["date"] = time.Now().Format("2006-01-02")

		// Default company from the current user's session
		companyID := env.CompanyID()
		if companyID > 0 {
			vals["company_id"] = companyID
		}

		// Default journal: first active sales journal for the company
		var journalID int64
		err := env.Tx().QueryRow(env.Ctx(),
			`SELECT id FROM account_journal
			 WHERE type = 'sale' AND active = true AND company_id = $1
			 ORDER BY sequence, id LIMIT 1`, companyID).Scan(&journalID)
		if err == nil && journalID > 0 {
			vals["journal_id"] = journalID
		}

		// Default currency from the company
		var currencyID int64
		err = env.Tx().QueryRow(env.Ctx(),
			`SELECT currency_id FROM res_company WHERE id = $1`, companyID).Scan(&currencyID)
		if err == nil && currencyID > 0 {
			vals["currency_id"] = currencyID
		}

		return vals
	}

	// -- Onchange: partner_id → auto-fill partner address fields --
	// Mirrors: odoo/addons/account/models/account_move.py _onchange_partner_id()
	// When the partner changes on an invoice, look up the partner's address
	// and populate the commercial_partner_id field.
	m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values {
		result := make(orm.Values)
		partnerID, ok := toInt64Arg(vals["partner_id"])
		if !ok || partnerID == 0 {
			return result
		}

		var name string
		var commercialID *int64
		err := env.Tx().QueryRow(env.Ctx(),
			`SELECT p.name, p.commercial_partner_id
			 FROM res_partner p WHERE p.id = $1`, partnerID,
		).Scan(&name, &commercialID)
		if err != nil {
			return result
		}

		if commercialID != nil && *commercialID > 0 {
			result["commercial_partner_id"] = *commercialID
		} else {
			result["commercial_partner_id"] = partnerID
		}
		return result
	})

	// -- Business Method: register_payment --
	// Create a payment for this invoice and reconcile.
	// Mirrors: odoo/addons/account/models/account_payment.py AccountPayment.action_register_payment()
	m.RegisterMethod("register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, moveID := range rs.IDs() {
			// Read invoice info
			var partnerID, journalID, companyID, currencyID int64
			var amountTotal float64
			var moveType string
			err := env.Tx().QueryRow(env.Ctx(),
				`SELECT COALESCE(partner_id,0), COALESCE(journal_id,0), COALESCE(company_id,0),
				 COALESCE(currency_id,0), COALESCE(amount_total,0), COALESCE(move_type,'entry')
				 FROM account_move WHERE id = $1`, moveID,
			).Scan(&partnerID, &journalID, &companyID, &currencyID, &amountTotal, &moveType)
			if err != nil {
				return nil, fmt.Errorf("account: read invoice %d for payment: %w", moveID, err)
			}

			// Determine payment type and partner type
			paymentType := "inbound"   // customer pays us
			partnerType := "customer"
			if moveType == "in_invoice" || moveType == "in_refund" {
				paymentType = "outbound" // we pay vendor
				partnerType = "supplier"
			}

			// Find bank journal
			var bankJournalID int64
			env.Tx().QueryRow(env.Ctx(),
				`SELECT id FROM account_journal WHERE type = 'bank' AND company_id = $1 LIMIT 1`,
				companyID).Scan(&bankJournalID)
			if bankJournalID == 0 {
				bankJournalID = journalID
			}

			// Create a journal entry for the payment
			var payMoveID int64
			err = env.Tx().QueryRow(env.Ctx(),
				`INSERT INTO account_move (name, move_type, state, date, partner_id, journal_id, company_id, currency_id)
				 VALUES ($1, 'entry', 'posted', NOW(), $2, $3, $4, $5) RETURNING id`,
				fmt.Sprintf("PAY/%d", moveID), partnerID, bankJournalID, companyID, currencyID,
			).Scan(&payMoveID)
			if err != nil {
				return nil, fmt.Errorf("account: create payment move for invoice %d: %w", moveID, err)
			}

			// Create payment record linked to the journal entry
			_, err = env.Tx().Exec(env.Ctx(),
				`INSERT INTO account_payment
				 (name, payment_type, partner_type, state, date, amount,
				  currency_id, journal_id, partner_id, company_id, move_id, is_reconciled)
				 VALUES ($1, $2, $3, 'paid', NOW(), $4, $5, $6, $7, $8, $9, true)`,
				fmt.Sprintf("PAY/%d", moveID), paymentType, partnerType, amountTotal,
				currencyID, bankJournalID, partnerID, companyID, payMoveID)
			if err != nil {
				return nil, fmt.Errorf("account: create payment for invoice %d: %w", moveID, err)
			}

			// Create journal entry lines on the payment move for reconciliation:
			// - Debit line on bank account (asset_cash)
			// - Credit line on receivable/payable account (mirrors invoice's payment_term line)

			// Find bank account for the journal
			var bankAccountID int64
			env.Tx().QueryRow(env.Ctx(),
				`SELECT COALESCE(default_account_id, 0) FROM account_journal WHERE id = $1`,
				bankJournalID).Scan(&bankAccountID)
			if bankAccountID == 0 {
				// Fallback: find any cash account
				env.Tx().QueryRow(env.Ctx(),
					`SELECT id FROM account_account WHERE account_type = 'asset_cash' AND company_id = $1 ORDER BY code LIMIT 1`,
					companyID).Scan(&bankAccountID)
			}

			// Find receivable/payable account from the invoice's payment_term line
			var invoiceReceivableAccountID int64
			env.Tx().QueryRow(env.Ctx(),
				`SELECT account_id FROM account_move_line
				 WHERE move_id = $1 AND display_type = 'payment_term'
				 LIMIT 1`, moveID).Scan(&invoiceReceivableAccountID)
			if invoiceReceivableAccountID == 0 {
				accountType := "asset_receivable"
				if moveType == "in_invoice" || moveType == "in_refund" {
					accountType = "liability_payable"
				}
				env.Tx().QueryRow(env.Ctx(),
					`SELECT id FROM account_account WHERE account_type = $1 AND company_id = $2 ORDER BY code LIMIT 1`,
					accountType, companyID).Scan(&invoiceReceivableAccountID)
			}

			if bankAccountID > 0 && invoiceReceivableAccountID > 0 {
				// Bank line (debit for inbound, credit for outbound)
				var bankDebit, bankCredit float64
				if paymentType == "inbound" {
					bankDebit = amountTotal
				} else {
					bankCredit = amountTotal
				}
				_, err = env.Tx().Exec(env.Ctx(),
					`INSERT INTO account_move_line
					 (move_id, name, account_id, partner_id, company_id, journal_id, currency_id,
					  debit, credit, balance, amount_residual, display_type)
					 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0, 'product')`,
					payMoveID, fmt.Sprintf("PAY/%d", moveID), bankAccountID, partnerID,
					companyID, bankJournalID, currencyID,
					bankDebit, bankCredit, bankDebit-bankCredit)
				if err != nil {
					return nil, fmt.Errorf("account: create bank line for payment %d: %w", moveID, err)
				}

				// Counterpart line on receivable/payable (opposite of bank line)
				var cpDebit, cpCredit float64
				var cpResidual float64
				if paymentType == "inbound" {
					cpCredit = amountTotal
					cpResidual = -amountTotal // Negative residual for credit line
				} else {
					cpDebit = amountTotal
					cpResidual = amountTotal
				}
				var paymentLineID int64
				err = env.Tx().QueryRow(env.Ctx(),
					`INSERT INTO account_move_line
					 (move_id, name, account_id, partner_id, company_id, journal_id, currency_id,
					  debit, credit, balance, amount_residual, display_type)
					 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'payment_term')
					 RETURNING id`,
					payMoveID, fmt.Sprintf("PAY/%d", moveID), invoiceReceivableAccountID, partnerID,
					companyID, bankJournalID, currencyID,
					cpDebit, cpCredit, cpDebit-cpCredit, cpResidual).Scan(&paymentLineID)
				if err != nil {
					return nil, fmt.Errorf("account: create counterpart line for payment %d: %w", moveID, err)
				}

				// Find the invoice's receivable/payable line and reconcile
				var invoiceLineID int64
				env.Tx().QueryRow(env.Ctx(),
					`SELECT id FROM account_move_line
					 WHERE move_id = $1 AND display_type = 'payment_term'
					 ORDER BY id LIMIT 1`, moveID).Scan(&invoiceLineID)

				if invoiceLineID > 0 && paymentLineID > 0 {
					lineModel := orm.Registry.Get("account.move.line")
					if lineModel != nil {
						if reconcileMethod, ok := lineModel.Methods["reconcile"]; ok {
							lineRS := env.Model("account.move.line").Browse(invoiceLineID, paymentLineID)
							if _, err := reconcileMethod(lineRS); err != nil {
								// Non-fatal: fall back to direct update
								env.Tx().Exec(env.Ctx(),
									`UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
							}
						} else {
							env.Tx().Exec(env.Ctx(),
								`UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
						}
					} else {
						env.Tx().Exec(env.Ctx(),
							`UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
					}
				} else {
					env.Tx().Exec(env.Ctx(),
						`UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
				}
			} else {
				// Fallback: direct payment state update (no reconciliation possible)
				env.Tx().Exec(env.Ctx(),
					`UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID)
			}
		}
		return true, nil
	})

	// -- BeforeCreate Hook: Generate sequence number --
	m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error {
		name, _ := vals["name"].(string)
		if name == "" || name == "/" {
			moveType, _ := vals["move_type"].(string)
			code := "account.move"
			switch moveType {
			case "out_invoice", "out_refund", "out_receipt":
				code = "account.move.out_invoice"
			case "in_invoice", "in_refund", "in_receipt":
				code = "account.move.in_invoice"
			}
			seq, err := orm.NextByCode(env, code)
			if err != nil {
				// Fallback to generic sequence
				seq, err = orm.NextByCode(env, "account.move")
				if err != nil {
					return nil // No sequence configured, keep "/"
				}
			}
			vals["name"] = seq
		}
		return nil
	}
}

// initAccountMoveLine registers account.move.line — journal items / invoice lines.
// Mirrors: odoo/addons/account/models/account_move_line.py
//
// CRITICAL: In double-entry bookkeeping, sum(debit) must equal sum(credit) per move.
func initAccountMoveLine() {
	m := orm.NewModel("account.move.line", orm.ModelOpts{
		Description: "Journal Item",
		Order:       "date desc, id",
	})

	// -- Parent --
	m.AddFields(
		orm.Many2one("move_id", "account.move", orm.FieldOpts{
			String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade, Index: true,
		}),
		orm.Char("move_name", orm.FieldOpts{String: "Journal Entry Name", Related: "move_id.name"}),
		orm.Date("date", orm.FieldOpts{String: "Date", Related: "move_id.date", Store: true, Index: true}),
		orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Index: true}),
	)

	// -- Accounts --
	m.AddFields(
		orm.Many2one("account_id", "account.account", orm.FieldOpts{
			String: "Account", Required: true, Index: true,
		}),
		orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner", Index: true}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
		orm.Many2one("company_currency_id", "res.currency", orm.FieldOpts{String: "Company Currency"}),
		orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
	)

	// -- Amounts (Double-Entry) --
	m.AddFields(
		orm.Monetary("debit", orm.FieldOpts{String: "Debit", Default: 0.0, CurrencyField: "company_currency_id"}),
		orm.Monetary("credit", orm.FieldOpts{String: "Credit", Default: 0.0, CurrencyField: "company_currency_id"}),
		orm.Monetary("balance", orm.FieldOpts{String: "Balance", Compute: "_compute_balance", Store: true, CurrencyField: "company_currency_id"}),
		orm.Monetary("amount_currency", orm.FieldOpts{String: "Amount in Currency", CurrencyField: "currency_id"}),
		orm.Float("amount_residual", orm.FieldOpts{String: "Residual Amount"}),
		orm.Float("amount_residual_currency", orm.FieldOpts{String: "Residual Amount in Currency"}),
	)

	// -- Invoice line fields --
	m.AddFields(
		orm.Char("name", orm.FieldOpts{String: "Label"}),
		orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1.0}),
		orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}),
		orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}),
		orm.Float("price_subtotal", orm.FieldOpts{String: "Subtotal", Compute: "_compute_totals", Store: true}),
		orm.Float("price_total", orm.FieldOpts{String: "Total", Compute: "_compute_totals", Store: true}),
	)

	// -- Tax --
	m.AddFields(
		orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{String: "Taxes"}),
		orm.Many2one("tax_line_id", "account.tax", orm.FieldOpts{String: "Originator Tax"}),
		orm.Many2one("tax_group_id", "account.tax.group", orm.FieldOpts{String: "Tax Group"}),
		orm.Many2one("tax_repartition_line_id", "account.tax.repartition.line", orm.FieldOpts{
			String: "Tax Repartition Line",
		}),
	)

	// -- Analytic & Tags --
	m.AddFields(
		orm.Many2many("tax_tag_ids", "account.account.tag", orm.FieldOpts{
			String:   "Tax Tags",
			Relation: "account_move_line_account_tag_rel",
			Column1:  "line_id",
			Column2:  "tag_id",
		}),
		orm.Json("analytic_distribution", orm.FieldOpts{
			String: "Analytic Distribution",
			Help:   "JSON distribution across analytic accounts, e.g. {\"42\": 100}",
		}),
	)

	// -- Maturity & Related --
	m.AddFields(
		orm.Date("date_maturity", orm.FieldOpts{String: "Due Date"}),
		orm.Selection("parent_state", []orm.SelectionItem{
			{Value: "draft", Label: "Draft"},
			{Value: "posted", Label: "Posted"},
			{Value: "cancel", Label: "Cancelled"},
		}, orm.FieldOpts{String: "Parent State", Related: "move_id.state", Store: true}),
	)

	// -- Display --
	m.AddFields(
		orm.Selection("display_type", []orm.SelectionItem{
			{Value: "product", Label: "Product"},
			{Value: "cogs", Label: "COGS"},
			{Value: "tax", Label: "Tax"},
			{Value: "rounding", Label: "Rounding"},
			{Value: "payment_term", Label: "Payment Term"},
			{Value: "line_section", Label: "Section"},
			{Value: "line_note", Label: "Note"},
			{Value: "epd", Label: "Early Payment Discount"},
		}, orm.FieldOpts{String: "Display Type", Default: "product"}),
		orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
	)

	// -- Reconciliation --
	m.AddFields(
		orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}),
		orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Matching"}),
		orm.Char("matching_number", orm.FieldOpts{
			String: "Matching #", Compute: "_compute_matching_number",
			Help: "P for partial, full reconcile name otherwise",
		}),
	)

	// _compute_matching_number: derives the matching display from reconcile state.
	// Mirrors: odoo/addons/account/models/account_move_line.py _compute_matching_number()
	m.RegisterCompute("matching_number", func(rs *orm.Recordset) (orm.Values, error) {
		env := rs.Env()
		lineID := rs.IDs()[0]

		var fullRecID *int64
		env.Tx().QueryRow(env.Ctx(),
			`SELECT full_reconcile_id FROM account_move_line WHERE id = $1`, lineID,
		).Scan(&fullRecID)

		if fullRecID != nil && *fullRecID > 0 {
			var name string
			env.Tx().QueryRow(env.Ctx(),
				`SELECT name FROM account_full_reconcile WHERE id = $1`, *fullRecID,
			).Scan(&name)
			return orm.Values{"matching_number": name}, nil
		}

		// Check if partially reconciled
		var partialCount int
		env.Tx().QueryRow(env.Ctx(),
			`SELECT COUNT(*) FROM account_partial_reconcile
			 WHERE debit_move_id = $1 OR credit_move_id = $1`, lineID,
		).Scan(&partialCount)

		if partialCount > 0 {
			return orm.Values{"matching_number": "P"}, nil
		}

		return orm.Values{"matching_number": ""}, nil
	})

	// reconcile: matches debit lines against credit lines and creates
	// account.partial.reconcile (and optionally account.full.reconcile) records.
	// Mirrors: odoo/addons/account/models/account_move_line.py AccountMoveLine.reconcile()
	m.RegisterMethod("reconcile", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		lineIDs := rs.IDs()
		if len(lineIDs) < 2 {
			return false, fmt.Errorf("reconcile requires at least 2 lines")
		}

		// Read line data with explicit float casts (numeric → float8 for Go compatibility)
		type reconLine struct {
			id       int64
			debit    float64
			credit   float64
			residual float64
			moveID   int64
		}
		var allLines []reconLine
		for _, lid := range lineIDs {
			var rl reconLine
			err := env.Tx().QueryRow(env.Ctx(),
				`SELECT id, COALESCE(debit::float8, 0), COALESCE(credit::float8, 0),
				        COALESCE(amount_residual::float8, 0), COALESCE(move_id, 0)
				 FROM account_move_line WHERE id = $1`, lid,
			).Scan(&rl.id, &rl.debit, &rl.credit, &rl.residual, &rl.moveID)
			if err != nil {
				continue
			}
			allLines = append(allLines, rl)
		}

		// Separate debit lines (receivable) from credit lines (payment)
		var debitLines, creditLines []*reconLine
		for i := range allLines {
			if allLines[i].debit > allLines[i].credit {
				debitLines = append(debitLines, &allLines[i])
			} else {
				creditLines = append(creditLines, &allLines[i])
			}
		}

		// Match debit <-> credit lines, creating partial reconciles
		partialRS := env.Model("account.partial.reconcile")
		var partialIDs []int64

		for _, dl := range debitLines {
			if dl.residual <= 0 {
				continue
			}

			for _, cl := range creditLines {
				if cl.residual >= 0 {
					continue // credit residual is negative
				}

				// Match amount = min of what's available
				matchAmount := dl.residual
				if -cl.residual < matchAmount {
					matchAmount = -cl.residual
				}
				if matchAmount <= 0 {
					continue
				}

				// Create partial reconcile
				partial, err := partialRS.Create(orm.Values{
					"debit_move_id":  dl.id,
					"credit_move_id": cl.id,
					"amount":         matchAmount,
				})
				if err != nil {
					return nil, err
				}
				partialIDs = append(partialIDs, partial.ID())

				// Update residuals directly in the database
				env.Tx().Exec(env.Ctx(),
					`UPDATE account_move_line SET amount_residual = amount_residual - $1 WHERE id = $2`,
					matchAmount, dl.id)
				env.Tx().Exec(env.Ctx(),
					`UPDATE account_move_line SET amount_residual = amount_residual + $1 WHERE id = $2`,
					matchAmount, cl.id)

				dl.residual -= matchAmount
				cl.residual += matchAmount

				if dl.residual <= 0.005 {
					break
				}
			}
		}

		// Check if fully reconciled (all residuals ~ 0)
		allResolved := true
		for _, rl := range allLines {
			var residual float64
			env.Tx().QueryRow(env.Ctx(),
				`SELECT COALESCE(amount_residual::float8, 0) FROM account_move_line WHERE id = $1`,
				rl.id).Scan(&residual)
			if residual > 0.005 || residual < -0.005 {
				allResolved = false
				break
			}
		}

		// If fully reconciled, create account.full.reconcile
		if allResolved && len(partialIDs) > 0 {
			fullRS := env.Model("account.full.reconcile")
			fullRec, err := fullRS.Create(orm.Values{
				"name": fmt.Sprintf("FULL-%d", partialIDs[0]),
			})
			if err == nil {
				for _, rl := range allLines {
					env.Tx().Exec(env.Ctx(),
						`UPDATE account_move_line SET full_reconcile_id = $1 WHERE id = $2`,
						fullRec.ID(), rl.id)
				}
				for _, pID := range partialIDs {
					env.Tx().Exec(env.Ctx(),
						`UPDATE account_partial_reconcile SET full_reconcile_id = $1 WHERE id = $2`,
						fullRec.ID(), pID)
				}
			}
		}

		// Update payment_state on linked invoices
		moveIDs := make(map[int64]bool)
		for _, rl := range allLines {
			if rl.moveID > 0 {
				moveIDs[rl.moveID] = true
			}
		}
		for moveID := range moveIDs {
			updatePaymentState(env, moveID)
		}

		return true, nil
	})

	// remove_move_reconcile: undo reconciliation on selected lines.
	// Mirrors: odoo/addons/account/models/account_move_line.py remove_move_reconcile()
	m.RegisterMethod("remove_move_reconcile", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, lineID := range rs.IDs() {
			// Find partial reconciles involving this line
			env.Tx().Exec(env.Ctx(),
				`DELETE FROM account_partial_reconcile WHERE debit_move_id = $1 OR credit_move_id = $1`, lineID)
			// Reset residual to balance
			env.Tx().Exec(env.Ctx(),
				`UPDATE account_move_line SET amount_residual = balance, full_reconcile_id = NULL WHERE id = $1`, lineID)
		}
		// Clean up orphaned full reconciles
		env.Tx().Exec(env.Ctx(),
			`DELETE FROM account_full_reconcile WHERE id NOT IN (SELECT DISTINCT full_reconcile_id FROM account_partial_reconcile WHERE full_reconcile_id IS NOT NULL)`)
		// Update payment states
		for _, lineID := range rs.IDs() {
			var moveID int64
			env.Tx().QueryRow(env.Ctx(), `SELECT move_id FROM account_move_line WHERE id = $1`, lineID).Scan(&moveID)
			if moveID > 0 {
				updatePaymentState(env, moveID)
			}
		}
		return true, nil
	})
}

// initAccountPayment registers account.payment.
// Mirrors: odoo/addons/account/models/account_payment.py
func initAccountPayment() {
	m := orm.NewModel("account.payment", orm.ModelOpts{
		Description: "Payments",
		Order:       "date desc, name desc",
	})

	m.AddFields(
		orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}),
		orm.Many2one("move_id", "account.move", orm.FieldOpts{
			String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade,
		}),
		orm.Selection("payment_type", []orm.SelectionItem{
			{Value: "outbound", Label: "Send"},
			{Value: "inbound", Label: "Receive"},
		}, orm.FieldOpts{String: "Payment Type", Required: true}),
		orm.Selection("partner_type", []orm.SelectionItem{
			{Value: "customer", Label: "Customer"},
			{Value: "supplier", Label: "Vendor"},
		}, orm.FieldOpts{String: "Partner Type"}),
		orm.Selection("state", []orm.SelectionItem{
			{Value: "draft", Label: "Draft"},
			{Value: "in_process", Label: "In Process"},
			{Value: "paid", Label: "Paid"},
			{Value: "canceled", Label: "Cancelled"},
			{Value: "rejected", Label: "Rejected"},
		}, orm.FieldOpts{String: "Status", Default: "draft"}),
		orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
		orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}),
		orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}),
		orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
		orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer/Vendor"}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
		orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{String: "Recipient Bank Account"}),
		orm.Many2one("destination_account_id", "account.account", orm.FieldOpts{String: "Destination Account"}),
		orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}),
		orm.Boolean("is_matched", orm.FieldOpts{String: "Is Matched With a Bank Statement"}),
		orm.Char("payment_reference", orm.FieldOpts{String: "Payment Reference"}),
		orm.Char("payment_method_code", orm.FieldOpts{String: "Payment Method Code"}),
	)

	// action_post: confirm and post the payment.
	// Mirrors: odoo/addons/account/models/account_payment.py action_post()
	m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, id := range rs.IDs() {
			if _, err := env.Tx().Exec(env.Ctx(),
				`UPDATE account_payment SET state = 'paid' WHERE id = $1 AND state = 'draft'`, id); err != nil {
				return nil, err
			}
		}
		return true, nil
	})

	// action_cancel: cancel the payment.
	m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, id := range rs.IDs() {
			if _, err := env.Tx().Exec(env.Ctx(),
				`UPDATE account_payment SET state = 'canceled' WHERE id = $1`, id); err != nil {
				return nil, err
			}
		}
		return true, nil
	})
}

// initAccountPaymentRegister registers the payment register wizard.
// Mirrors: odoo/addons/account/wizard/account_payment_register.py
// This is a TransientModel wizard opened via "Register Payment" button on invoices.
func initAccountPaymentRegister() {
	m := orm.NewModel("account.payment.register", orm.ModelOpts{
		Description: "Register Payment",
		Type:        orm.ModelTransient,
	})

	m.AddFields(
		orm.Date("payment_date", orm.FieldOpts{String: "Payment Date", Required: true}),
		orm.Monetary("amount", orm.FieldOpts{String: "Amount", CurrencyField: "currency_id"}),
		orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}),
		orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
		orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer/Vendor"}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
		orm.Selection("payment_type", []orm.SelectionItem{
			{Value: "outbound", Label: "Send"},
			{Value: "inbound", Label: "Receive"},
		}, orm.FieldOpts{String: "Payment Type", Default: "inbound"}),
		orm.Selection("partner_type", []orm.SelectionItem{
			{Value: "customer", Label: "Customer"},
			{Value: "supplier", Label: "Vendor"},
		}, orm.FieldOpts{String: "Partner Type", Default: "customer"}),
		orm.Char("communication", orm.FieldOpts{String: "Memo"}),
		// Context-only: which invoice(s) are being paid
		orm.Many2many("line_ids", "account.move.line", orm.FieldOpts{
			String:   "Journal items",
			Relation: "payment_register_move_line_rel",
			Column1:  "wizard_id",
			Column2:  "line_id",
		}),
	)

	// action_create_payments: create account.payment from the wizard and mark invoice as paid.
	// Mirrors: odoo/addons/account/wizard/account_payment_register.py action_create_payments()
	m.RegisterMethod("action_create_payments", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		wizardData, err := rs.Read([]string{
			"payment_date", "amount", "currency_id", "journal_id",
			"partner_id", "company_id", "payment_type", "partner_type", "communication",
		})
		if err != nil || len(wizardData) == 0 {
			return nil, fmt.Errorf("account: cannot read payment register wizard")
		}
		wiz := wizardData[0]

		paymentRS := env.Model("account.payment")
		paymentVals := orm.Values{
			"payment_type":      wiz["payment_type"],
			"partner_type":      wiz["partner_type"],
			"amount":            wiz["amount"],
			"date":              wiz["payment_date"],
			"currency_id":       wiz["currency_id"],
			"journal_id":        wiz["journal_id"],
			"partner_id":        wiz["partner_id"],
			"company_id":        wiz["company_id"],
			"payment_reference": wiz["communication"],
			"state":             "draft",
		}

		payment, err := paymentRS.Create(paymentVals)
		if err != nil {
			return nil, fmt.Errorf("account: create payment: %w", err)
		}

		// Auto-post the payment
		paymentModel := orm.Registry.Get("account.payment")
		if paymentModel != nil {
			if postMethod, ok := paymentModel.Methods["action_post"]; ok {
				if _, err := postMethod(payment); err != nil {
					return nil, fmt.Errorf("account: post payment: %w", err)
				}
			}
		}

		// Reconcile: link payment to invoices via partial reconcile records.
		// Mirrors: odoo/addons/account/wizard/account_payment_register.py _reconcile_payments()
		if ctx := env.Context(); ctx != nil {
			if activeIDs, ok := ctx["active_ids"].([]interface{}); ok {
				paymentAmount := floatArg(wiz["amount"], 0)
				paymentID := payment.ID()

				for _, rawID := range activeIDs {
					moveID, ok := toInt64Arg(rawID)
					if !ok || moveID <= 0 {
						continue
					}

					// Find the invoice's receivable/payable line
					var invoiceLineID int64
					var invoiceResidual float64
					env.Tx().QueryRow(env.Ctx(),
						`SELECT id, COALESCE(amount_residual, 0) FROM account_move_line
						 WHERE move_id = $1 AND display_type = 'payment_term'
						 ORDER BY id LIMIT 1`, moveID).Scan(&invoiceLineID, &invoiceResidual)

					if invoiceLineID == 0 || invoiceResidual <= 0 {
						// Fallback: direct update
						env.Tx().Exec(env.Ctx(),
							`UPDATE account_move SET payment_state = 'paid' WHERE id = $1 AND state = 'posted'`, moveID)
						continue
					}

					// Determine match amount
					matchAmount := paymentAmount
					if invoiceResidual < matchAmount {
						matchAmount = invoiceResidual
					}
					if matchAmount <= 0 {
						continue
					}

					// Find the payment's journal entry lines (counterpart)
					var paymentMoveID int64
					env.Tx().QueryRow(env.Ctx(),
						`SELECT COALESCE(move_id, 0) FROM account_payment WHERE id = $1`,
						paymentID).Scan(&paymentMoveID)

					var paymentLineID int64
					if paymentMoveID > 0 {
						env.Tx().QueryRow(env.Ctx(),
							`SELECT id FROM account_move_line
							 WHERE move_id = $1 AND display_type = 'payment_term'
							 ORDER BY id LIMIT 1`, paymentMoveID).Scan(&paymentLineID)
					}

					if paymentLineID > 0 {
						// Use the reconcile method
						lineModel := orm.Registry.Get("account.move.line")
						if lineModel != nil {
							if reconcileMethod, mOk := lineModel.Methods["reconcile"]; mOk {
								lineRS := env.Model("account.move.line").Browse(invoiceLineID, paymentLineID)
								if _, rErr := reconcileMethod(lineRS); rErr == nil {
									continue // reconcile handled payment_state update
								}
							}
						}
					}

					// Fallback: create partial reconcile manually and update state
					env.Tx().Exec(env.Ctx(),
						`INSERT INTO account_partial_reconcile (debit_move_id, credit_move_id, amount)
						 VALUES ($1, $2, $3)`,
						invoiceLineID, invoiceLineID, matchAmount)
					env.Tx().Exec(env.Ctx(),
						`UPDATE account_move_line SET amount_residual = amount_residual - $1 WHERE id = $2`,
						matchAmount, invoiceLineID)
					updatePaymentState(env, moveID)
				}
			}
		}

		// Return action to close wizard (standard Odoo pattern)
		return map[string]interface{}{
			"type": "ir.actions.act_window_close",
		}, nil
	})

	// DefaultGet: pre-fill wizard from active invoice context.
	// Mirrors: odoo/addons/account/wizard/account_payment_register.py default_get()
	m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values {
		vals := orm.Values{
			"payment_date": time.Now().Format("2006-01-02"),
		}

		ctx := env.Context()
		if ctx == nil {
			return vals
		}

		// Get active invoice IDs from context
		var moveIDs []int64
		if ids, ok := ctx["active_ids"].([]interface{}); ok {
			for _, rawID := range ids {
				if id, ok := toInt64Arg(rawID); ok && id > 0 {
					moveIDs = append(moveIDs, id)
				}
			}
		}
		if len(moveIDs) == 0 {
			return vals
		}

		// Read first invoice to pre-fill defaults
		moveRS := env.Model("account.move").Browse(moveIDs[0])
		moveData, err := moveRS.Read([]string{
			"partner_id", "company_id", "currency_id", "amount_residual", "move_type",
		})
		if err != nil || len(moveData) == 0 {
			return vals
		}
		mv := moveData[0]

		if pid, ok := toInt64Arg(mv["partner_id"]); ok && pid > 0 {
			vals["partner_id"] = pid
		}
		if cid, ok := toInt64Arg(mv["company_id"]); ok && cid > 0 {
			vals["company_id"] = cid
		}
		if curID, ok := toInt64Arg(mv["currency_id"]); ok && curID > 0 {
			vals["currency_id"] = curID
		}
		if amt, ok := mv["amount_residual"].(float64); ok {
			vals["amount"] = amt
		}

		// Determine payment type from move type
		moveType, _ := mv["move_type"].(string)
		switch moveType {
		case "out_invoice", "out_receipt":
			vals["payment_type"] = "inbound"
			vals["partner_type"] = "customer"
		case "in_invoice", "in_receipt":
			vals["payment_type"] = "outbound"
			vals["partner_type"] = "supplier"
		}

		// Default bank journal
		var journalID int64
		companyID := env.CompanyID()
		env.Tx().QueryRow(env.Ctx(),
			`SELECT id FROM account_journal
			 WHERE type = 'bank' AND active = true AND company_id = $1
			 ORDER BY sequence, id LIMIT 1`, companyID).Scan(&journalID)
		if journalID > 0 {
			vals["journal_id"] = journalID
		}

		return vals
	}
}

// initAccountPaymentTerm registers payment terms.
// Mirrors: odoo/addons/account/models/account_payment_term.py
func initAccountPaymentTerm() {
	m := orm.NewModel("account.payment.term", orm.ModelOpts{
		Description: "Payment Terms",
		Order:       "sequence, id",
	})

	m.AddFields(
		orm.Char("name", orm.FieldOpts{String: "Payment Terms", Required: true, Translate: true}),
		orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}),
		orm.Text("note", orm.FieldOpts{String: "Description on the Invoice", Translate: true}),
		orm.One2many("line_ids", "account.payment.term.line", "payment_id", orm.FieldOpts{String: "Terms"}),
		orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
		orm.Selection("early_discount", []orm.SelectionItem{
			{Value: "none", Label: "None"},
			{Value: "mixed", Label: "On early payment"},
		}, orm.FieldOpts{String: "Early Discount", Default: "none"}),
		orm.Float("discount_percentage", orm.FieldOpts{String: "Discount %"}),
		orm.Integer("discount_days", orm.FieldOpts{String: "Discount Days"}),
	)

	// Payment term lines — each line defines a portion
	orm.NewModel("account.payment.term.line", orm.ModelOpts{
		Description: "Payment Terms Line",
		Order:       "sequence, id",
	}).AddFields(
		orm.Many2one("payment_id", "account.payment.term", orm.FieldOpts{
			String: "Payment Terms", Required: true, OnDelete: orm.OnDeleteCascade,
		}),
		orm.Selection("value", []orm.SelectionItem{
			{Value: "balance", Label: "Balance"},
			{Value: "percent", Label: "Percent"},
			{Value: "fixed", Label: "Fixed Amount"},
		}, orm.FieldOpts{String: "Type", Required: true, Default: "balance"}),
		orm.Float("value_amount", orm.FieldOpts{String: "Value"}),
		orm.Integer("nb_days", orm.FieldOpts{String: "Days", Required: true, Default: 0}),
		orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}),
	)
}

// initAccountReconcile registers reconciliation models.
// Mirrors: odoo/addons/account/models/account_reconcile_model.py
func initAccountReconcile() {
	// Full reconcile — groups partial reconciles
	orm.NewModel("account.full.reconcile", orm.ModelOpts{
		Description: "Full Reconcile",
	}).AddFields(
		orm.Char("name", orm.FieldOpts{String: "Name", Required: true}),
		orm.One2many("partial_reconcile_ids", "account.partial.reconcile", "full_reconcile_id", orm.FieldOpts{String: "Reconciliation Parts"}),
		orm.One2many("reconciled_line_ids", "account.move.line", "full_reconcile_id", orm.FieldOpts{String: "Matched Journal Items"}),
		orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}),
	)

	// Partial reconcile — matches debit ↔ credit lines
	orm.NewModel("account.partial.reconcile", orm.ModelOpts{
		Description: "Partial Reconcile",
	}).AddFields(
		orm.Many2one("debit_move_id", "account.move.line", orm.FieldOpts{String: "Debit line", Required: true, Index: true}),
		orm.Many2one("credit_move_id", "account.move.line", orm.FieldOpts{String: "Credit line", Required: true, Index: true}),
		orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Full Reconcile"}),
		orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true}),
		orm.Monetary("debit_amount_currency", orm.FieldOpts{String: "Debit Amount Currency"}),
		orm.Monetary("credit_amount_currency", orm.FieldOpts{String: "Credit Amount Currency"}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}),
		orm.Many2one("debit_currency_id", "res.currency", orm.FieldOpts{String: "Debit Currency"}),
		orm.Many2one("credit_currency_id", "res.currency", orm.FieldOpts{String: "Credit Currency"}),
		orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}),
	)
}

// initAccountBankStatement registers bank statement models.
// Mirrors: odoo/addons/account/models/account_bank_statement.py
func initAccountBankStatement() {
	m := orm.NewModel("account.bank.statement", orm.ModelOpts{
		Description: "Bank Statement",
		Order:       "date desc, name desc, id desc",
	})

	m.AddFields(
		orm.Char("name", orm.FieldOpts{String: "Reference"}),
		orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
		orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
		orm.Float("balance_start", orm.FieldOpts{String: "Starting Balance"}),
		orm.Float("balance_end_real", orm.FieldOpts{String: "Ending Balance"}),
		orm.Float("balance_end", orm.FieldOpts{String: "Computed Balance", Compute: "_compute_balance_end"}),
		orm.One2many("line_ids", "account.bank.statement.line", "statement_id", orm.FieldOpts{String: "Statement Lines"}),
	)

	// Bank statement line
	stLine := orm.NewModel("account.bank.statement.line", orm.ModelOpts{
		Description: "Bank Statement Line",
		Order:       "internal_index desc, sequence, id desc",
	})
	stLine.AddFields(
		orm.Many2one("statement_id", "account.bank.statement", orm.FieldOpts{String: "Statement"}),
		orm.Many2one("move_id", "account.move", orm.FieldOpts{String: "Journal Entry", Required: true}),
		orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}),
		orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}),
		orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner"}),
		orm.Char("payment_ref", orm.FieldOpts{String: "Label"}),
		orm.Date("date", orm.FieldOpts{String: "Date", Required: true}),
		orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}),
		orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}),
		orm.Char("transaction_type", orm.FieldOpts{String: "Transaction Type"}),
		orm.Char("account_number", orm.FieldOpts{String: "Bank Account Number"}),
		orm.Char("partner_name", orm.FieldOpts{String: "Partner Name"}),
		orm.Char("narration", orm.FieldOpts{String: "Notes"}),
		orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}),
		orm.Char("internal_index", orm.FieldOpts{String: "Internal Index"}),
		orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}),
		orm.Many2one("move_line_id", "account.move.line", orm.FieldOpts{String: "Matched Journal Item"}),
	)

	// button_match: automatically match bank statement lines to open invoices.
	// Mirrors: odoo/addons/account/models/account_bank_statement_line.py _find_or_create_bank_statement()
	stLine.RegisterMethod("button_match", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) {
		env := rs.Env()
		for _, lineID := range rs.IDs() {
			var amount float64
			var partnerID *int64
			err := env.Tx().QueryRow(env.Ctx(),
				`SELECT COALESCE(amount::float8, 0), partner_id FROM account_bank_statement_line WHERE id = $1`, lineID,
			).Scan(&amount, &partnerID)
			if err != nil {
				return nil, fmt.Errorf("account: read statement line %d: %w", lineID, err)
			}

			if partnerID == nil {
				continue
			}

			// Find unreconciled move lines for this partner with matching amount
			var matchLineID int64
			env.Tx().QueryRow(env.Ctx(),
				`SELECT l.id FROM account_move_line l
				 JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
				 JOIN account_account a ON a.id = l.account_id
				 WHERE l.partner_id = $1
				 AND a.account_type IN ('asset_receivable', 'liability_payable')
				 AND ABS(COALESCE(l.amount_residual::float8, 0)) BETWEEN ABS($2) * 0.99 AND ABS($2) * 1.01
				 AND COALESCE(l.amount_residual, 0) != 0
				 ORDER BY ABS(COALESCE(l.amount_residual::float8, 0) - ABS($2)) LIMIT 1`,
				*partnerID, amount,
			).Scan(&matchLineID)

			if matchLineID > 0 {
				// Mark as matched
				env.Tx().Exec(env.Ctx(),
					`UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`,
					matchLineID, lineID)
			}
		}
		return true, nil
	})
}

// -- Helper functions for argument parsing in business methods --

// toInt64Arg converts various numeric types (float64, int64, int, int32) to int64.
// Returns (value, true) on success, (0, false) if not convertible.
func toInt64Arg(v interface{}) (int64, bool) {
	switch n := v.(type) {
	case int64:
		return n, true
	case float64:
		return int64(n), true
	case int:
		return int64(n), true
	case int32:
		return int64(n), true
	}
	return 0, false
}

// floatArg extracts a float64 from an interface{}, returning defaultVal if not possible.
func floatArg(v interface{}, defaultVal float64) float64 {
	switch n := v.(type) {
	case float64:
		return n
	case int64:
		return float64(n)
	case int:
		return float64(n)
	case int32:
		return float64(n)
	}
	return defaultVal
}

// toFloat converts various numeric types to float64.
// Returns (value, true) on success, (0, false) if not convertible.
func toFloat(v interface{}) (float64, bool) {
	switch n := v.(type) {
	case float64:
		return n, true
	case int64:
		return float64(n), true
	case int:
		return float64(n), true
	case int32:
		return float64(n), true
	case float32:
		return float64(n), true
	default:
		// Handle pgx numeric types (returned as string-like or Numeric struct)
		if s, ok := v.(fmt.Stringer); ok {
			var f float64
			if _, err := fmt.Sscanf(s.String(), "%f", &f); err == nil {
				return f, true
			}
		}
		// Try string conversion as last resort
		if s, ok := v.(string); ok {
			var f float64
			if _, err := fmt.Sscanf(s, "%f", &f); err == nil {
				return f, true
			}
		}
	}
	return 0, false
}

// updatePaymentState recomputes payment_state on an account.move based on its
// payment_term lines' residual amounts.
// Mirrors: odoo/addons/account/models/account_move.py _compute_payment_state()
func updatePaymentState(env *orm.Environment, moveID int64) {
	var total, residual float64
	env.Tx().QueryRow(env.Ctx(),
		`SELECT COALESCE(SUM(ABS(balance)), 0), COALESCE(SUM(ABS(amount_residual)), 0)
		 FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term'`,
		moveID).Scan(&total, &residual)

	state := "not_paid"
	if total > 0 {
		if residual < 0.005 {
			state = "paid"
		} else if residual < total-0.005 {
			state = "partial"
		}
	}
	env.Tx().Exec(env.Ctx(),
		`UPDATE account_move SET payment_state = $1 WHERE id = $2`, state, moveID)
}
