<?php
/**
 * PettyCashBox — Slice 8Z-e · Batch 3
 *
 * Single-box MVP. Every disbursement, replenishment, retirement-return, or manual adjustment
 * writes a `petty_cash_movements` row + updates `petty_cash_box.current_balance` transactionally.
 * The running balance on the box row is authoritative; movements are the audit trail.
 *
 * Fires a CASH_BOX_LOW notification to Finance Manager the first time balance drops below
 * `low_water_pct` of `float_amount` — so FM can replenish before the next disbursement fails.
 */
class PettyCashBox extends Model
{
    /** Load the (single) active box. */
    public function primary(): ?array
    {
        $db = new DB();
        $rs = $db->mySelect("SELECT * FROM petty_cash_box WHERE is_active = 1 ORDER BY c0d LIMIT 1", []);
        return $rs[0] ?? null;
    }

    public function currentBalance(): float
    {
        $b = $this->primary();
        return $b ? (float)$b['current_balance'] : 0.0;
    }

    /**
     * Path A · disburse an APPROVED internal-cash LPO straight from the box.
     * Flips lpo.cash_out_status → PAID and marks the box movement.
     * Refuses if balance < amount (Accountant needs to replenish first).
     */
    public function disburse(int $lpoId, int $userId, string $userName): array
    {
        $box = $this->primary();
        if (!$box) return ['ok' => false, 'msg' => 'No active petty cash box configured.'];
        $bal = (float)$box['current_balance'];

        $db  = new DB();
        $lpoRs = $db->mySelect("SELECT c0d, lpo_no, lpo_type, amount, currency, cash_out_status, requested_by FROM lpos WHERE c0d = :id AND deleted = 0", [':id' => $lpoId]);
        if (empty($lpoRs))                                            return ['ok' => false, 'msg' => 'LPO not found.'];
        $lpo = $lpoRs[0];
        if (!in_array($lpo['lpo_type'], Lpos::INTERNAL_CASH_TYPES, true)) return ['ok' => false, 'msg' => 'Supplier LPOs use the PV pipeline, not petty cash.'];
        // Slice 8Z-l · petty cash bypass path — small amounts can disburse straight from BOOKED
        // (skip the Payment Schedule); larger amounts must go through the schedule first (APPROVED).
        if (!in_array($lpo['cash_out_status'], ['BOOKED','APPROVED'], true)) return ['ok' => false, 'msg' => 'LPO is ' . $lpo['cash_out_status'] . ' — only BOOKED / APPROVED cash LPOs can be disbursed from the box.'];
        if ($lpo['currency'] !== $box['currency'])                    return ['ok' => false, 'msg' => 'Currency mismatch (LPO=' . $lpo['currency'] . ' · Box=' . $box['currency'] . ').'];
        $amt = (float)$lpo['amount'];
        if ($amt <= 0)                                                return ['ok' => false, 'msg' => 'LPO amount is zero.'];
        if ($amt > $bal)                                              return ['ok' => false, 'msg' => 'Insufficient float — box has ' . number_format($bal) . ' but LPO needs ' . number_format($amt) . '. Replenish first.'];

        $newBal = round($bal - $amt, 2);
        $db->insertUpdate(
            "INSERT INTO petty_cash_movements (box_id, movement_type, amount, balance_after, related_lpo_id, narrative, performed_by, performed_by_name)
             VALUES (:b, 'DISBURSE', :a, :n, :l, :nar, :u, :un)",
            [':b'=>(int)$box['c0d'], ':a'=>$amt, ':n'=>$newBal, ':l'=>$lpoId,
             ':nar'=>'Disbursed ' . $lpo['lpo_no'], ':u'=>$userId, ':un'=>$userName]
        );
        $db->insertUpdate("UPDATE petty_cash_box SET current_balance = :n, edited_by = :u, edited_on = NOW() WHERE c0d = :b",
            [':n'=>$newBal, ':u'=>$userId, ':b'=>(int)$box['c0d']]);
        $db->insertUpdate("UPDATE lpos SET cash_out_status = 'PAID', edited_on = NOW() WHERE c0d = :id", [':id'=>$lpoId]);

        // Notify requester the cash is ready
        try {
            if (!class_exists('Notifications')) require_once ROOT . '/app/models/Notifications.php';
            (new Notifications())->notify(
                (int)$lpo['requested_by'], 'CASH_DISBURSED',
                'Cash ready to collect: ' . $lpo['lpo_no'],
                $lpo['currency'] . ' ' . number_format($amt) . ' from Head Office petty cash box.',
                'Lpos/view/' . $lpoId, 'lpo', $lpoId, $userId, $userName
            );
        } catch (\Throwable $e) { /* best-effort */ }

        // Low-water check → notify FM
        $this->maybeNotifyLowWater($box, $newBal, $userId, $userName);

        return ['ok' => true, 'balance_after' => $newBal, 'msg' => 'Disbursed ' . number_format($amt) . '. Box balance: ' . number_format($newBal) . '.'];
    }

    /** Top up the box (bank → tin). Amount must be > 0. */
    public function replenish(float $amount, string $narrative, int $userId, string $userName): array
    {
        if ($amount <= 0) return ['ok' => false, 'msg' => 'Amount must be > 0.'];
        $box = $this->primary();
        if (!$box)        return ['ok' => false, 'msg' => 'No active petty cash box.'];
        $newBal = round((float)$box['current_balance'] + $amount, 2);
        $db = new DB();
        $db->insertUpdate(
            "INSERT INTO petty_cash_movements (box_id, movement_type, amount, balance_after, narrative, performed_by, performed_by_name)
             VALUES (:b, 'REPLENISH', :a, :n, :nar, :u, :un)",
            [':b'=>(int)$box['c0d'], ':a'=>$amount, ':n'=>$newBal, ':nar'=>trim($narrative) ?: 'Float top-up', ':u'=>$userId, ':un'=>$userName]
        );
        $db->insertUpdate("UPDATE petty_cash_box SET current_balance = :n, edited_by = :u, edited_on = NOW() WHERE c0d = :b",
            [':n'=>$newBal, ':u'=>$userId, ':b'=>(int)$box['c0d']]);
        return ['ok' => true, 'balance_after' => $newBal];
    }

    /**
     * Unused cash returned via a retirement. Called from LpoRetirements::approve, only if the
     * original disbursement came from this box (checked via an existing DISBURSE movement).
     */
    public function recordReturn(int $lpoId, int $retirementId, float $amount, int $userId, string $userName): array
    {
        if ($amount <= 0) return ['ok' => false, 'msg' => 'Nothing to return.'];
        $box = $this->primary();
        if (!$box)        return ['ok' => false, 'msg' => 'No active petty cash box.'];

        // Only return if the original disbursement was from this box
        $db = new DB();
        $prev = $db->mySelect(
            "SELECT c0d FROM petty_cash_movements WHERE related_lpo_id = :l AND movement_type = 'DISBURSE' LIMIT 1",
            [':l' => $lpoId]
        );
        if (empty($prev)) return ['ok' => false, 'msg' => 'Original disbursement not from petty cash — no return.'];

        $newBal = round((float)$box['current_balance'] + $amount, 2);
        $db->insertUpdate(
            "INSERT INTO petty_cash_movements (box_id, movement_type, amount, balance_after, related_lpo_id, related_retirement_id, narrative, performed_by, performed_by_name)
             VALUES (:b, 'RETURN', :a, :n, :l, :r, :nar, :u, :un)",
            [':b'=>(int)$box['c0d'], ':a'=>$amount, ':n'=>$newBal, ':l'=>$lpoId, ':r'=>$retirementId,
             ':nar'=>'Unused cash returned on retirement approval', ':u'=>$userId, ':un'=>$userName]
        );
        $db->insertUpdate("UPDATE petty_cash_box SET current_balance = :n, edited_by = :u, edited_on = NOW() WHERE c0d = :b",
            [':n'=>$newBal, ':u'=>$userId, ':b'=>(int)$box['c0d']]);
        return ['ok' => true, 'balance_after' => $newBal];
    }

    /** Manual correction (over/short) with mandatory narrative. FM only in the perm layer. */
    public function adjustment(float $signedAmount, string $narrative, int $userId, string $userName): array
    {
        if ($signedAmount == 0)          return ['ok' => false, 'msg' => 'Amount must be non-zero.'];
        if (trim($narrative) === '')     return ['ok' => false, 'msg' => 'Narrative required — explain the over/short.'];
        $box = $this->primary();
        if (!$box)                        return ['ok' => false, 'msg' => 'No active petty cash box.'];
        $newBal = round((float)$box['current_balance'] + $signedAmount, 2);
        if ($newBal < 0)                  return ['ok' => false, 'msg' => 'Adjustment would drive balance negative.'];
        $db = new DB();
        $db->insertUpdate(
            "INSERT INTO petty_cash_movements (box_id, movement_type, amount, balance_after, narrative, performed_by, performed_by_name)
             VALUES (:b, 'ADJUSTMENT', :a, :n, :nar, :u, :un)",
            [':b'=>(int)$box['c0d'], ':a'=>$signedAmount, ':n'=>$newBal, ':nar'=>trim($narrative), ':u'=>$userId, ':un'=>$userName]
        );
        $db->insertUpdate("UPDATE petty_cash_box SET current_balance = :n, edited_by = :u, edited_on = NOW() WHERE c0d = :b",
            [':n'=>$newBal, ':u'=>$userId, ':b'=>(int)$box['c0d']]);
        return ['ok' => true, 'balance_after' => $newBal];
    }

    /** Update box config (float, custodian, thresholds). */
    public function updateConfig(array $d, int $userId): array
    {
        $box = $this->primary();
        if (!$box) return ['ok' => false, 'msg' => 'No active petty cash box.'];
        $db = new DB();
        $db->insertUpdate(
            "UPDATE petty_cash_box SET
                name = :nm, custodian_id = :cid, custodian_name = :cn,
                float_amount = :fa, max_disbursement = :md, low_water_pct = :lw,
                edited_by = :u, edited_on = NOW()
              WHERE c0d = :b",
            [
                ':nm' => (string)($d['name'] ?? $box['name']),
                ':cid'=> !empty($d['custodian_id']) ? (int)$d['custodian_id'] : null,
                ':cn' => (string)($d['custodian_name'] ?? ''),
                ':fa' => (float)($d['float_amount'] ?? $box['float_amount']),
                ':md' => (float)($d['max_disbursement'] ?? $box['max_disbursement']),
                ':lw' => max(1, min(100, (int)($d['low_water_pct'] ?? $box['low_water_pct']))),
                ':u'  => $userId,
                ':b'  => (int)$box['c0d'],
            ]
        );
        return ['ok' => true];
    }

    /** Paged movement log — newest first. */
    public function movements(int $limit = 100, int $offset = 0, string $type = ''): array
    {
        $db = new DB();
        $where = "WHERE 1=1";
        $p = [];
        if ($type !== '') { $where .= " AND movement_type = :t"; $p[':t'] = $type; }
        $rows = $db->mySelect(
            "SELECT m.*, l.lpo_no, l.lpo_type
               FROM petty_cash_movements m
          LEFT JOIN lpos l ON l.c0d = m.related_lpo_id
               $where
              ORDER BY m.c0d DESC
              LIMIT $limit OFFSET $offset",
            $p
        ) ?: [];
        return $rows;
    }

    /* ── Internals ─────────────────────────────────────────────────────── */

    private function maybeNotifyLowWater(array $box, float $newBalance, int $userId, string $userName): void
    {
        $float = (float)$box['float_amount'];
        if ($float <= 0) return; // no target set → nothing to compare against
        $threshold = $float * ((int)$box['low_water_pct'] / 100.0);
        if ($newBalance >= $threshold) return;

        // Debounce: only notify if the previous DISBURSE didn't already trigger (last DISBURSE also below threshold)
        $db = new DB();
        $prev = $db->mySelect(
            "SELECT balance_after FROM petty_cash_movements
              WHERE box_id = :b AND movement_type = 'DISBURSE'
              ORDER BY c0d DESC LIMIT 1 OFFSET 1",
            [':b' => (int)$box['c0d']]
        );
        $prevBal = isset($prev[0]) ? (float)$prev[0]['balance_after'] : PHP_FLOAT_MAX;
        if ($prevBal < $threshold) return; // already low, don't spam

        try {
            if (!class_exists('Notifications')) require_once ROOT . '/app/models/Notifications.php';
            (new Notifications())->notifyGroup(
                'Finance Manager', 'CASH_BOX_LOW',
                'Petty cash low: ' . $box['name'],
                'Balance dropped to ' . $box['currency'] . ' ' . number_format($newBalance) . ' — replenish before next disbursement.',
                'PettyCashBox', 'petty_cash_box', (int)$box['c0d'], $userId, $userName
            );
        } catch (\Throwable $e) { /* best-effort */ }
    }
}
