<?php

namespace Modules\MedAccounting\Services;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\MedAccounting\Entities\MedaccAccount;
use Modules\MedAccounting\Entities\MedaccJournalEntry;

/**
 * AutoPostingService (SIFA P7-T2) — the ERP-event → balanced-journal-entry mapper.
 *
 * This is the BINDING layer between the ERP's existing data and the new GL. It
 * READS the ERP `transactions` / `transaction_payments` (the same rows the POS and
 * MedBilling already create) and DERIVES a balanced journal entry per event,
 * mapping to seeded standard-medical COA codes. It NEVER hooks core models or
 * mutates ERP data — postings are call-on-demand plus the medacc:sync-postings
 * backfill command, so the core stays byte-identical (ground rule).
 *
 * Idempotent per (reference_type, reference_id): MedaccJournalEntry::existsForReference
 * guards every post, so re-running (or the sync command) never double-posts.
 *
 * Mappings (per SIFA §25 auto_journal_entries; exact COA codes come from
 * config('medaccounting.accounts')):
 *   payment  (transaction_payments) → Dr Cash/Bank (by method)   Cr Accounts Receivable
 *   invoice  (sell, type=sell)      → Dr Accounts Receivable       Cr Revenue (+ Cr Tax Payable)
 *   purchase (type=purchase)        → Dr Inventory/Expense         Cr Accounts Payable (+ Dr Tax)
 *   expense  (type=expense)         → Dr Expense                   Cr Accounts Payable
 *   payroll  (type=payroll)         → Dr Salary Expense            Cr Payroll Payable (+ Cr deductions→Payroll Payable)
 *
 * Fail-soft (ground rule): a missing account code, a missing table/column, or an
 * unresolved event is skipped (logged), never thrown — the GL degrades gracefully
 * on partial hosts.
 */
class AutoPostingService
{
    protected LedgerService $ledger;

    public function __construct(LedgerService $ledger)
    {
        $this->ledger = $ledger;
    }

    // ── Event → journal entry ────────────────────────────────────────────────

    /**
     * Post a payment receipt: Dr Cash/Bank (chosen by payment method), Cr AR.
     * Reads a single transaction_payments row.
     */
    public function postPayment(int $businessId, $payment): ?MedaccJournalEntry
    {
        if (MedaccJournalEntry::existsForReference($businessId, 'payment', $payment->id)) {
            return null;
        }

        $amount = round((float) $payment->amount, 4);
        if ($amount <= 0) {
            return null; // refunds / returns are out of the base P7-T2 mapping — skipped fail-soft
        }

        $methodKey = config('medaccounting.payment_method_account.' . ($payment->method ?? 'other'), 'bank');
        $cashOrBank = $this->accountByConfig($businessId, $methodKey);
        $ar = $this->accountByConfig($businessId, 'accounts_receivable');

        if (! $cashOrBank || ! $ar) {
            return $this->skip('payment', $payment->id, 'cash/bank or AR account missing');
        }

        return $this->safePost([
            'business_id'    => $businessId,
            'date'           => $this->dateOf($payment->paid_on ?? $payment->created_at),
            'description'    => __('medaccounting::lang.auto_payment_desc', ['id' => $payment->transaction_id]),
            'reference_type' => 'payment',
            'reference_id'   => $payment->id,
        ], [
            ['account_id' => $cashOrBank->id, 'debit' => $amount, 'credit' => 0, 'description' => __('medaccounting::lang.auto_payment_desc', ['id' => $payment->transaction_id])],
            ['account_id' => $ar->id, 'debit' => 0, 'credit' => $amount, 'description' => __('medaccounting::lang.auto_ar_settle')],
        ]);
    }

    /**
     * Post a finalized sell/invoice: Dr AR, Cr Revenue, Cr Tax Payable (when the
     * transaction carries tax). Reads one `transactions` row (type=sell).
     */
    public function postInvoice(int $businessId, $txn): ?MedaccJournalEntry
    {
        if (MedaccJournalEntry::existsForReference($businessId, 'invoice', $txn->id)) {
            return null;
        }

        $final = round((float) $txn->final_total, 4);
        if ($final <= 0) {
            return null;
        }

        $tax = round((float) ($txn->tax_amount ?? 0), 4);
        $revenue = round($final - $tax, 4);

        $ar = $this->accountByConfig($businessId, 'accounts_receivable');
        $rev = $this->accountByConfig($businessId, 'sales_revenue');
        $taxAcc = $this->accountByConfig($businessId, 'tax_payable');

        if (! $ar || ! $rev) {
            return $this->skip('invoice', $txn->id, 'AR or Revenue account missing');
        }

        $lines = [
            ['account_id' => $ar->id, 'debit' => $final, 'credit' => 0, 'description' => __('medaccounting::lang.auto_invoice_ar')],
            ['account_id' => $rev->id, 'debit' => 0, 'credit' => $revenue, 'description' => __('medaccounting::lang.auto_invoice_rev')],
        ];
        if ($tax > 0 && $taxAcc) {
            $lines[] = ['account_id' => $taxAcc->id, 'debit' => 0, 'credit' => $tax, 'description' => __('medaccounting::lang.auto_invoice_tax')];
        } elseif ($tax > 0) {
            // No tax-payable account — fold tax into revenue so the entry still balances (flagged).
            $lines[1]['credit'] = $final;
        }

        return $this->safePost([
            'business_id'    => $businessId,
            'date'           => $this->dateOf($txn->transaction_date ?? $txn->created_at),
            'description'    => __('medaccounting::lang.auto_invoice_desc', ['no' => $txn->invoice_no ?? $txn->id]),
            'reference_type' => 'invoice',
            'reference_id'   => $txn->id,
        ], $lines);
    }

    /**
     * Post a purchase: Dr Inventory (goods) + Dr Tax (input), Cr Accounts Payable.
     * Reads one `transactions` row (type=purchase).
     */
    public function postPurchase(int $businessId, $txn): ?MedaccJournalEntry
    {
        if (MedaccJournalEntry::existsForReference($businessId, 'purchase', $txn->id)) {
            return null;
        }

        $final = round((float) $txn->final_total, 4);
        if ($final <= 0) {
            return null;
        }

        $tax = round((float) ($txn->tax_amount ?? 0), 4);
        $goods = round($final - $tax, 4);

        $inventory = $this->accountByConfig($businessId, 'inventory')
            ?: $this->accountByConfig($businessId, 'purchase_expense');
        $ap = $this->accountByConfig($businessId, 'accounts_payable');
        $taxAcc = $this->accountByConfig($businessId, 'tax_payable');

        if (! $inventory || ! $ap) {
            return $this->skip('purchase', $txn->id, 'Inventory/Expense or AP account missing');
        }

        $lines = [
            ['account_id' => $inventory->id, 'debit' => $tax > 0 && $taxAcc ? $goods : $final, 'credit' => 0, 'description' => __('medaccounting::lang.auto_purchase_goods')],
            ['account_id' => $ap->id, 'debit' => 0, 'credit' => $final, 'description' => __('medaccounting::lang.auto_purchase_ap')],
        ];
        if ($tax > 0 && $taxAcc) {
            $lines[] = ['account_id' => $taxAcc->id, 'debit' => $tax, 'credit' => 0, 'description' => __('medaccounting::lang.auto_purchase_tax')];
        }

        return $this->safePost([
            'business_id'    => $businessId,
            'date'           => $this->dateOf($txn->transaction_date ?? $txn->created_at),
            'description'    => __('medaccounting::lang.auto_purchase_desc', ['no' => $txn->ref_no ?? $txn->id]),
            'reference_type' => 'purchase',
            'reference_id'   => $txn->id,
        ], $lines);
    }

    /**
     * Post an expense: Dr Expense, Cr Accounts Payable. Reads one `transactions`
     * row (type=expense).
     */
    public function postExpense(int $businessId, $txn): ?MedaccJournalEntry
    {
        if (MedaccJournalEntry::existsForReference($businessId, 'expense', $txn->id)) {
            return null;
        }

        $final = round((float) $txn->final_total, 4);
        if ($final <= 0) {
            return null;
        }

        $expense = $this->accountByConfig($businessId, 'general_expense');
        $ap = $this->accountByConfig($businessId, 'accounts_payable');

        if (! $expense || ! $ap) {
            return $this->skip('expense', $txn->id, 'Expense or AP account missing');
        }

        return $this->safePost([
            'business_id'    => $businessId,
            'date'           => $this->dateOf($txn->transaction_date ?? $txn->created_at),
            'description'    => __('medaccounting::lang.auto_expense_desc', ['no' => $txn->ref_no ?? $txn->id]),
            'reference_type' => 'expense',
            'reference_id'   => $txn->id,
        ], [
            ['account_id' => $expense->id, 'debit' => $final, 'credit' => 0, 'description' => __('medaccounting::lang.auto_expense_line')],
            ['account_id' => $ap->id, 'debit' => 0, 'credit' => $final, 'description' => __('medaccounting::lang.auto_expense_ap')],
        ]);
    }

    /**
     * Post a payroll run: Dr Salary Expense (gross), Cr Payroll Payable (net + any
     * deductions). On this ERP payroll is a `transactions` row (type=payroll) with
     * gross in final_total and a JSON essentials_deductions column.
     */
    public function postPayroll(int $businessId, $txn): ?MedaccJournalEntry
    {
        if (MedaccJournalEntry::existsForReference($businessId, 'payroll', $txn->id)) {
            return null;
        }

        $gross = round((float) ($txn->final_total ?? $txn->total_before_tax ?? 0), 4);
        if ($gross <= 0) {
            return null;
        }

        $salaryExpense = $this->accountByConfig($businessId, 'salary_expense');
        $payrollPayable = $this->accountByConfig($businessId, 'payroll_payable');

        if (! $salaryExpense || ! $payrollPayable) {
            return $this->skip('payroll', $txn->id, 'Salary Expense or Payroll Payable account missing');
        }

        // Whole gross is credited to Payroll Payable (net + deductions all settle
        // through the payable in this simplified base mapping — deductions detail
        // is available on essentials_deductions for a future split).
        return $this->safePost([
            'business_id'    => $businessId,
            'date'           => $this->dateOf($txn->transaction_date ?? $txn->created_at),
            'description'    => __('medaccounting::lang.auto_payroll_desc', ['no' => $txn->ref_no ?? $txn->id]),
            'reference_type' => 'payroll',
            'reference_id'   => $txn->id,
        ], [
            ['account_id' => $salaryExpense->id, 'debit' => $gross, 'credit' => 0, 'description' => __('medaccounting::lang.auto_payroll_expense')],
            ['account_id' => $payrollPayable->id, 'debit' => 0, 'credit' => $gross, 'description' => __('medaccounting::lang.auto_payroll_payable')],
        ]);
    }

    // ── Backfill ─────────────────────────────────────────────────────────────

    /**
     * Scan recent ERP events for a business and post any missing journal entries.
     * Idempotent; safe to re-run. Returns per-type counts.
     *
     * @param  string|null  $since  Y-m-d lower bound on transaction_date / paid_on
     * @return array<string,int>
     */
    public function backfill(int $businessId, ?string $since = null): array
    {
        $counts = ['invoice' => 0, 'payment' => 0, 'purchase' => 0, 'expense' => 0, 'payroll' => 0];

        if (! Schema::hasTable('transactions')) {
            return $counts;
        }

        // Sells / purchases / expenses / payroll from the transactions table.
        $typeMap = [
            'sell'     => 'invoice',
            'purchase' => 'purchase',
            'expense'  => 'expense',
            'payroll'  => 'payroll',
        ];

        foreach ($typeMap as $erpType => $refType) {
            $q = DB::table('transactions')
                ->where('business_id', $businessId)
                ->where('type', $erpType);

            // Only finalized documents post (drafts/quotations are excluded).
            if ($erpType === 'sell' || $erpType === 'purchase') {
                $q->where('status', 'final');
            }
            if ($since) {
                $q->whereDate('transaction_date', '>=', $since);
            }

            $q->orderBy('id')->chunkById(500, function ($rows) use ($businessId, $erpType, $refType, &$counts) {
                foreach ($rows as $txn) {
                    try {
                        $entry = match ($erpType) {
                            'sell'     => $this->postInvoice($businessId, $txn),
                            'purchase' => $this->postPurchase($businessId, $txn),
                            'expense'  => $this->postExpense($businessId, $txn),
                            'payroll'  => $this->postPayroll($businessId, $txn),
                            default    => null,
                        };
                        if ($entry) {
                            $counts[$refType]++;
                        }
                    } catch (\Throwable $e) {
                        \Log::warning("MedAccounting backfill {$erpType} #{$txn->id} skipped: " . $e->getMessage());
                    }
                }
            });
        }

        // Payments from transaction_payments (joined to the sell/business).
        if (Schema::hasTable('transaction_payments')) {
            $pq = DB::table('transaction_payments as tp')
                ->join('transactions as t', 'tp.transaction_id', '=', 't.id')
                ->where('t.business_id', $businessId)
                ->where('t.type', 'sell')
                ->when(Schema::hasColumn('transaction_payments', 'is_return'), fn ($q) => $q->where('tp.is_return', 0))
                ->select('tp.*');

            if ($since) {
                $pq->whereDate('tp.paid_on', '>=', $since);
            }

            $pq->orderBy('tp.id')->chunk(500, function ($rows) use ($businessId, &$counts) {
                foreach ($rows as $payment) {
                    try {
                        if ($this->postPayment($businessId, $payment)) {
                            $counts['payment']++;
                        }
                    } catch (\Throwable $e) {
                        \Log::warning("MedAccounting backfill payment #{$payment->id} skipped: " . $e->getMessage());
                    }
                }
            });
        }

        return $counts;
    }

    // ── helpers ──────────────────────────────────────────────────────────────

    /** Resolve a COA account by its config key → code. Null when the code is absent. */
    protected function accountByConfig(int $businessId, string $key): ?MedaccAccount
    {
        $code = config('medaccounting.accounts.' . $key);
        if (empty($code)) {
            return null;
        }

        return MedaccAccount::where('business_id', $businessId)
            ->where('code', $code)
            ->where('is_active', true)
            ->first();
    }

    /** Post via LedgerService, swallowing validation errors fail-soft (logged). */
    protected function safePost(array $header, array $lines): ?MedaccJournalEntry
    {
        try {
            return $this->ledger->postEntry($header, $lines);
        } catch (\Throwable $e) {
            \Log::warning('MedAccounting auto-post skipped (' . ($header['reference_type'] ?? '?') . ' #' . ($header['reference_id'] ?? '?') . '): ' . $e->getMessage());

            return null;
        }
    }

    protected function skip(string $refType, $refId, string $why): ?MedaccJournalEntry
    {
        \Log::info("MedAccounting auto-post skipped {$refType} #{$refId}: {$why}");

        return null;
    }

    protected function dateOf($value): string
    {
        try {
            return $value ? \Carbon\Carbon::parse($value)->toDateString() : \Carbon\Carbon::now()->toDateString();
        } catch (\Throwable $e) {
            return \Carbon\Carbon::now()->toDateString();
        }
    }
}
