<?php

namespace Modules\MedBilling\Services;

use App\Business;
use App\Utils\ProductUtil;
use App\Utils\TransactionUtil;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\MedBilling\Entities\MedbillChargeItem;
use Modules\MedBilling\Entities\MedbillClaim;
use Modules\MedBilling\Entities\MedbillClaimItem;
use Modules\MedBilling\Entities\MedbillPatientPolicy;

/**
 * ChargeCaptureService (SIFA P4-T1) — the charge-capture engine.
 *
 * captureForAppointment(): idempotently drafts medbill_charge_items from a
 * ClinicCore encounter (consultation fee + unbilled orders + dispensed Rx).
 *
 * billCharges(): groups draft charges by patient and creates ONE ERP sell per
 * patient through app/Utils/TransactionUtil — the SAME path the POS uses
 * (createSellTransaction -> createOrUpdateSellLines -> decreaseProductQuantity
 * -> updatePaymentStatus -> mapPurchaseSell). It never rebuilds invoicing.
 *
 * GROUND-RULE DEVIATIONS (flagged, per Rule 6):
 *  - Only charges that resolve to a real ERP product (product_id + variation_id)
 *    become ERP sell lines. The ERP has no free-text service-line path through
 *    createOrUpdateSellLines (confirmed: ClinicCore itself bills every service as
 *    a real product). Product-less charges stay draft + flagged.
 *  - Charges are billed as tax-free service lines (item_tax=0, tax_id=null). Tax
 *    rules per payer are out of scope for P4-T1; keeping lines tax-free makes the
 *    claimed==charged split exact and avoids entangling the ERP tax engine.
 *  - Everything is fail-soft: a missing ClinicCore table / column is skipped, not
 *    thrown, so the module degrades gracefully on partial hosts.
 */
class ChargeCaptureService
{
    protected TransactionUtil $transactionUtil;

    protected ProductUtil $productUtil;

    public function __construct()
    {
        $this->transactionUtil = app(TransactionUtil::class);
        $this->productUtil = app(ProductUtil::class);
    }

    /**
     * Idempotently draft charge items for a completed appointment.
     *
     * Sources:
     *   - consultation fee   (clc_doctors.consultation_fee, product via doctor.product_id)
     *   - unbilled orders    (clc_order_items.service_name; price via linked product if any)
     *   - dispensed Rx       (clc_prescription_items where dispensed_at set; medicine.product_id)
     *
     * Re-running never duplicates: each source row maps to at most one
     * (source_type, source_id) charge.
     *
     * @return array{created:int, skipped:int, charges:\Illuminate\Support\Collection}
     */
    public function captureForAppointment(int $appointmentId): array
    {
        $created = 0;
        $skipped = 0;

        $appt = DB::table('clc_appointments')->where('id', $appointmentId)->first();
        if (! $appt) {
            return ['created' => 0, 'skipped' => 0, 'charges' => collect()];
        }

        $businessId = (int) $appt->business_id;
        $patientId = (int) $appt->patient_id;
        $userId = auth()->check() ? (int) auth()->id() : null;

        DB::beginTransaction();
        try {
            // 1. Consultation fee ------------------------------------------------
            if (! empty($appt->doctor_id)) {
                $doctor = DB::table('clc_doctors')->where('id', $appt->doctor_id)->first();
                if ($doctor) {
                    $fee = (float) ($doctor->consultation_fee ?? 0);
                    [$productId, $variationId, $variationPrice] = $this->resolveProductVariation($doctor->product_id ?? null);
                    if (! $variationId) {
                        // Doctors carry no product link on this platform — fall back to
                        // the business's consultation service product so the charge can
                        // become a real ERP sell line. The doctor's fee stays the price.
                        [$productId, $variationId, $variationPrice] = $this->resolveProductByName($businessId, 'consult');
                    }
                    $unitPrice = $fee > 0 ? $fee : (float) ($variationPrice ?? 0);

                    if ($this->draftCharge($businessId, [
                        'appointment_id' => $appointmentId,
                        'patient_id'     => $patientId,
                        'source_type'    => 'clc_appointment',
                        'source_id'      => $appointmentId,
                        'description'    => 'Consultation — ' . ($doctor->name ?? 'Doctor'),
                        'product_id'     => $productId,
                        'variation_id'   => $variationId,
                        'qty'            => 1,
                        'unit_price'     => $unitPrice,
                        'created_by'     => $userId,
                    ])) {
                        $created++;
                    } else {
                        $skipped++;
                    }
                }
            }

            // 2. Unbilled clinical orders (clc_order_items) ----------------------
            if ($this->tableExists('clc_orders') && $this->tableExists('clc_order_items')) {
                $orderItems = DB::table('clc_order_items')
                    ->join('clc_orders', 'clc_order_items.order_id', '=', 'clc_orders.id')
                    ->where('clc_orders.business_id', $businessId)
                    ->where('clc_orders.appointment_id', $appointmentId)
                    ->where('clc_order_items.status', '!=', 'cancelled')
                    ->select('clc_order_items.*')
                    ->get();

                foreach ($orderItems as $oi) {
                    // clc_order_items carry no product_id — price resolves to 0 and
                    // the row is flagged (non-billable to ERP) unless a match is found.
                    [$productId, $variationId, $variationPrice] = $this->resolveOrderItemProduct($businessId, $oi);
                    $unitPrice = (float) ($variationPrice ?? 0);

                    if ($this->draftCharge($businessId, [
                        'appointment_id' => $appointmentId,
                        'patient_id'     => $patientId,
                        'source_type'    => 'clc_order_item',
                        'source_id'      => (int) $oi->id,
                        'description'    => 'Order — ' . ($oi->service_name ?? 'Service'),
                        'product_id'     => $productId,
                        'variation_id'   => $variationId,
                        'qty'            => (float) ($oi->quantity ?? 1),
                        'unit_price'     => $unitPrice,
                        'created_by'     => $userId,
                    ])) {
                        $created++;
                    } else {
                        $skipped++;
                    }
                }
            }

            // 3. Dispensed prescription items ------------------------------------
            if ($this->tableExists('clc_prescriptions') && $this->tableExists('clc_prescription_items')) {
                $rxItems = DB::table('clc_prescription_items')
                    ->join('clc_prescriptions', 'clc_prescription_items.prescription_id', '=', 'clc_prescriptions.id')
                    ->where('clc_prescriptions.business_id', $businessId)
                    ->where('clc_prescriptions.appointment_id', $appointmentId)
                    ->whereNotNull('clc_prescription_items.dispensed_at')
                    ->select('clc_prescription_items.*')
                    ->get();

                foreach ($rxItems as $ri) {
                    $productId = null;
                    $variationId = null;
                    $variationPrice = 0;
                    if (! empty($ri->medicine_id) && $this->tableExists('clc_medicines')) {
                        $medicine = DB::table('clc_medicines')->where('id', $ri->medicine_id)->first();
                        [$productId, $variationId, $variationPrice] = $this->resolveProductVariation($medicine->product_id ?? null);
                    }
                    $qty = (float) ($ri->dispensed_qty ?? $ri->quantity ?? 1);

                    if ($this->draftCharge($businessId, [
                        'appointment_id' => $appointmentId,
                        'patient_id'     => $patientId,
                        'source_type'    => 'clc_prescription_item',
                        'source_id'      => (int) $ri->id,
                        'description'    => 'Medicine — ' . ($ri->medicine_name ?? 'Item'),
                        'product_id'     => $productId,
                        'variation_id'   => $variationId,
                        'qty'            => $qty > 0 ? $qty : 1,
                        'unit_price'     => (float) ($variationPrice ?? 0),
                        'created_by'     => $userId,
                    ])) {
                        $created++;
                    } else {
                        $skipped++;
                    }
                }
            }

            DB::commit();
        } catch (\Throwable $e) {
            DB::rollBack();
            \Log::warning('MedBilling captureForAppointment failed: ' . $e->getMessage());

            return ['created' => 0, 'skipped' => 0, 'charges' => collect(), 'error' => $e->getMessage()];
        }

        $charges = MedbillChargeItem::forBusiness($businessId)
            ->where('appointment_id', $appointmentId)
            ->orderBy('id')
            ->get();

        return ['created' => $created, 'skipped' => $skipped, 'charges' => $charges];
    }

    /**
     * Bill a set of draft charges. Groups by patient, creates ONE ERP sell per
     * patient (only the product-resolvable charges become sell lines), writes
     * transaction_id / transaction_sell_line_id back onto each charge and marks
     * it billed. When a policy is given, splits the total per the policy and
     * drafts one claim per (sell, policy).
     *
     * @return array{sells:array, billed:int, unbillable:int, claims:array, errors:array}
     */
    public function billCharges(array $chargeIds, int $locationId, ?int $policyId = null): array
    {
        $result = ['sells' => [], 'billed' => 0, 'unbillable' => 0, 'claims' => [], 'errors' => []];

        $businessId = (int) request()->session()->get('user.business_id');
        $userId = auth()->check() ? (int) auth()->id() : null;

        $charges = MedbillChargeItem::forBusiness($businessId)
            ->whereIn('id', $chargeIds)
            ->where('status', 'draft')
            ->get();

        if ($charges->isEmpty()) {
            $result['errors'][] = __('medbilling::lang.no_billable_charges');

            return $result;
        }

        $policy = null;
        if ($policyId) {
            $policy = MedbillPatientPolicy::forBusiness($businessId)->find($policyId);
        }

        foreach ($charges->groupBy('patient_id') as $patientId => $patientCharges) {
            // Only product-resolvable charges can become ERP sell lines.
            $billable = $patientCharges->filter(fn ($c) => $c->isBillable());
            $unbillable = $patientCharges->reject(fn ($c) => $c->isBillable());
            $result['unbillable'] += $unbillable->count();

            if ($billable->isEmpty()) {
                $result['errors'][] = __('medbilling::lang.patient_no_billable', ['patient' => $patientId]);
                continue;
            }

            $contactId = $this->resolveContactId($businessId, (int) $patientId);
            if (! $contactId) {
                $result['errors'][] = __('medbilling::lang.patient_no_contact', ['patient' => $patientId]);
                continue;
            }

            try {
                $sell = $this->createErpSell($businessId, $locationId, $contactId, $userId, $billable);
                $result['sells'][] = $sell['transaction_id'];
                $result['billed'] += $billable->count();

                // Draft a claim when a policy is supplied for this patient.
                if ($policy && (int) $policy->patient_id === (int) $patientId) {
                    $claim = $this->draftClaim($businessId, $policy, (int) $patientId, $sell, $billable);
                    if ($claim) {
                        $result['claims'][] = $claim->id;
                    }
                }
            } catch (\Throwable $e) {
                \Log::emergency('MedBilling billCharges sell failed: ' . $e->getMessage());
                $result['errors'][] = $e->getMessage();
            }
        }

        return $result;
    }

    /**
     * Create the ERP sell for one patient's billable charges. Mirrors EXACTLY the
     * canonical SellPosController::store recipe (header -> lines -> stock ->
     * payment status -> purchase mapping), passing raw floats ($uf_data=false).
     *
     * @return array{transaction_id:int, final_total:float, line_map:array}
     */
    protected function createErpSell(int $businessId, int $locationId, int $contactId, ?int $userId, $billable): array
    {
        // Build the products payload in the exact shape createOrUpdateSellLines +
        // calculateInvoiceTotal + the stock loop expect.
        $products = [];
        foreach ($billable as $charge) {
            $unitPrice = (float) $charge->unit_price;
            $qty = (float) $charge->qty;

            $productMeta = DB::table('products')->where('id', $charge->product_id)->first();

            $products[] = [
                'medbill_charge_id'   => $charge->id, // internal correlation only (stripped before Util)
                'product_id'          => (int) $charge->product_id,
                'variation_id'        => (int) $charge->variation_id,
                'quantity'            => $qty,
                'unit_price'          => $unitPrice,          // exc-tax (tax-free lines)
                'unit_price_inc_tax'  => $unitPrice,          // no line tax
                'item_tax'            => 0,
                'tax_id'              => null,
                'enable_stock'        => (int) ($productMeta->enable_stock ?? 0),
                'product_type'        => $productMeta->type ?? 'single',
                'base_unit_multiplier' => 1,
            ];
        }

        $discount = ['discount_type' => 'fixed', 'discount_amount' => 0];
        $invoiceTotal = $this->productUtil->calculateInvoiceTotal($products, null, $discount, false);

        return DB::transaction(function () use ($businessId, $locationId, $contactId, $userId, $products, $invoiceTotal, $billable) {
            $input = [
                'location_id'      => $locationId,
                'type'             => 'sell',
                'status'           => 'final',
                'contact_id'       => $contactId,
                'transaction_date' => Carbon::now()->toDateTimeString(),
                'final_total'      => $invoiceTotal['final_total'],
                'discount_type'    => 'fixed',
                'discount_amount'  => 0,
                'tax_rate_id'      => null,
                'is_direct_sale'   => 1,
                'commission_agent' => null,
                'sale_note'        => 'MedBilling charge capture',
            ];

            $transaction = $this->transactionUtil->createSellTransaction(
                $businessId, $input, $invoiceTotal, $userId, false
            );

            // Strip our correlation key before handing lines to the Util.
            $sellProducts = array_map(function ($p) {
                unset($p['medbill_charge_id']);

                return $p;
            }, $products);

            $this->transactionUtil->createOrUpdateSellLines(
                $transaction, $sellProducts, $locationId, false, null, [], false
            );

            // Decrement stock for stock-enabled products (services usually aren't).
            foreach ($products as $p) {
                if (! empty($p['enable_stock'])) {
                    $this->productUtil->decreaseProductQuantity(
                        $p['product_id'], $p['variation_id'], $locationId, $p['quantity']
                    );
                }
            }

            // No payment recorded here — this is an unpaid invoice; the claim /
            // patient settles it later. updatePaymentStatus stamps it 'due'.
            $this->transactionUtil->updatePaymentStatus($transaction->id, $transaction->final_total);

            // FIFO/LIFO purchase-sell mapping for stock-tracked lines (fail-soft).
            try {
                $business = Business::find($businessId);
                $this->transactionUtil->mapPurchaseSell([
                    'id'                => $businessId,
                    'accounting_method' => $business->accounting_method ?? 'fifo',
                    'location_id'       => $locationId,
                    'pos_settings'      => [],
                ], $transaction->sell_lines, 'purchase');
            } catch (\Throwable $e) {
                \Log::warning('MedBilling mapPurchaseSell skipped: ' . $e->getMessage());
            }

            // Write the ERP ids back onto each charge (match by product+variation).
            $sellLines = $transaction->sell_lines()->orderBy('id')->get();
            foreach ($billable as $charge) {
                $line = $sellLines->first(function ($l) use ($charge) {
                    return (int) $l->product_id === (int) $charge->product_id
                        && (int) $l->variation_id === (int) $charge->variation_id
                        && $l->medbill_taken !== true;
                });
                if ($line) {
                    $line->medbill_taken = true; // in-memory guard so two identical charges map to distinct lines
                }
                $charge->update([
                    'status'                   => 'billed',
                    'transaction_id'           => $transaction->id,
                    'transaction_sell_line_id' => $line ? $line->id : null,
                ]);
            }

            return [
                'transaction_id' => $transaction->id,
                'final_total'    => (float) $transaction->final_total,
            ];
        });
    }

    /**
     * Draft one claim (+ items) for a billed sell under a policy. The claimed
     * total equals the billed total; patient_share / payer_share come from the
     * policy split. Idempotent per (transaction, policy).
     */
    protected function draftClaim(int $businessId, MedbillPatientPolicy $policy, int $patientId, array $sell, $billable): ?MedbillClaim
    {
        $existing = MedbillClaim::forBusiness($businessId)
            ->where('transaction_id', $sell['transaction_id'])
            ->where('policy_id', $policy->id)
            ->first();
        if ($existing) {
            return $existing;
        }

        $totalClaimed = 0.0;
        foreach ($billable as $charge) {
            $totalClaimed += $charge->lineTotal();
        }

        $split = $policy->splitAmount($totalClaimed);

        $claim = MedbillClaim::create([
            'business_id'    => $businessId,
            'claim_no'       => MedbillClaim::nextClaimNo($businessId),
            'payer_id'       => $policy->payer_id,
            'policy_id'      => $policy->id,
            'patient_id'     => $patientId,
            'transaction_id' => $sell['transaction_id'],
            'total_claimed'  => $totalClaimed,
            'patient_share'  => $split['patient_share'],
            'payer_share'    => $split['payer_share'],
            'status'         => 'draft',
            'pre_auth_no'    => null,
            'notes'          => null,
        ]);

        foreach ($billable as $charge) {
            MedbillClaimItem::create([
                'claim_id'        => $claim->id,
                'charge_item_id'  => $charge->id,
                'description'     => $charge->description,
                'claimed_amount'  => $charge->lineTotal(),
            ]);
        }

        return $claim;
    }

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

    /**
     * Insert a draft charge unless one already exists for the same
     * (business, source_type, source_id). Returns true when a new row is created.
     */
    protected function draftCharge(int $businessId, array $attrs): bool
    {
        $exists = MedbillChargeItem::forBusiness($businessId)
            ->where('source_type', $attrs['source_type'])
            ->where('source_id', $attrs['source_id'])
            ->exists();
        if ($exists) {
            return false;
        }

        MedbillChargeItem::create(array_merge(['business_id' => $businessId, 'status' => 'draft'], $attrs));

        return true;
    }

    /**
     * Resolve an ERP product's primary variation + default sell price.
     *
     * @return array{0:?int,1:?int,2:float} [product_id, variation_id, price]
     */
    protected function resolveProductVariation($productId): array
    {
        if (empty($productId)) {
            return [null, null, 0.0];
        }

        $variation = DB::table('variations')
            ->where('product_id', $productId)
            ->whereNull('deleted_at')
            ->orderBy('id')
            ->first(['id', 'default_sell_price']);

        if (! $variation) {
            return [(int) $productId, null, 0.0];
        }

        return [(int) $productId, (int) $variation->id, (float) $variation->default_sell_price];
    }

    /**
     * Best-effort product resolution for a clinical order item. clc_order_items
     * carry no product_id, so we try to match the service to a
     * clc_soap_options.product_id by label. Returns [null, null, 0] when nothing
     * resolves (row stays non-billable, flagged in the UI).
     *
     * @return array{0:?int,1:?int,2:float}
     */
    protected function resolveOrderItemProduct(int $businessId, $orderItem): array
    {
        if (! $this->tableExists('clc_soap_options')) {
            return [null, null, 0.0];
        }

        $productId = DB::table('clc_soap_options')
            ->where('label', $orderItem->service_name ?? '')
            ->where(function ($q) use ($businessId) {
                $q->where('business_id', $businessId)->orWhereNull('business_id');
            })
            ->whereNotNull('product_id')
            ->orderByRaw('business_id IS NULL')
            ->value('product_id');

        // ClinicLab test catalog carries the ERP product link for lab services.
        if (empty($productId) && $this->tableExists('cll_test_catalog')) {
            $service = trim((string) ($orderItem->service_name ?? ''));
            if ($service !== '') {
                $productId = DB::table('cll_test_catalog')
                    ->where('business_id', $businessId)
                    ->whereNotNull('product_id')
                    ->where(function ($q) use ($service) {
                        $q->where('name', $service)->orWhere('code', $service);
                    })
                    ->value('product_id');
            }
        }

        if (! empty($productId)) {
            return $this->resolveProductVariation($productId);
        }

        // Last resort: a business product named like the service.
        return $this->resolveProductByName($businessId, (string) ($orderItem->service_name ?? ''));
    }

    /**
     * Find a business product by (partial) name match and return its primary
     * variation. Exact match wins over prefix/contains. [null, null, 0] when
     * nothing matches — the charge stays flagged non-billable.
     *
     * @return array{0:?int,1:?int,2:float}
     */
    protected function resolveProductByName(int $businessId, string $needle): array
    {
        $needle = trim($needle);
        if ($needle === '') {
            return [null, null, 0.0];
        }

        $base = DB::table('products')->where('business_id', $businessId);
        // products has no soft-deletes on this core; exclude retired rows only
        // via the columns that actually exist.
        foreach (['is_inactive' => 0, 'not_for_selling' => 0] as $col => $val) {
            if (\Illuminate\Support\Facades\Schema::hasColumn('products', $col)) {
                $base->where($col, $val);
            }
        }

        $productId = (clone $base)->where('name', $needle)->value('id')
            ?? (clone $base)->where('name', 'like', $needle . '%')->orderBy('name')->value('id')
            ?? (clone $base)->where('name', 'like', '%' . $needle . '%')->orderBy('name')->value('id');

        return $this->resolveProductVariation($productId);
    }

    /** Resolve the ERP contacts.id (payer of record) for a clc_patients row. */
    protected function resolveContactId(int $businessId, int $patientId): ?int
    {
        $contactId = DB::table('clc_patients')
            ->where('business_id', $businessId)
            ->where('id', $patientId)
            ->value('contact_id');

        return $contactId ? (int) $contactId : null;
    }

    protected function tableExists(string $table): bool
    {
        try {
            return \Illuminate\Support\Facades\Schema::hasTable($table);
        } catch (\Throwable $e) {
            return false;
        }
    }
}
