<?php

namespace App\Services;

use App\Models\Lookups\Status;
use App\Models\Sales\CashMovement;
use App\Models\Sales\PosSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use RuntimeException;

class PosSessionService
{
    public function __construct(private readonly CashMovementService $cashMovements) {}

    /**
     * Opens a session. The opening balance must match the till's previous
     * closing balance unless a reason is given: a till's cash carries forward
     * from one shift to the next, and any deviation (a top-up, a shortage
     * carried over, cash pulled for banking) needs to be auditable rather
     * than silently accepted.
     */
    public function open(int $branchId, int $tillId, float $openingBalance, ?string $note = null): PosSession
    {
        return DB::transaction(function () use ($branchId, $tillId, $openingBalance, $note) {
            $openId = Status::idOf(Status::CTX_POS_SESSION, Status::SESSION_OPEN);

            $alreadyOpen = PosSession::query()
                ->where('till_id', $tillId)
                ->where('status_id', $openId)
                ->exists();
            if ($alreadyOpen) {
                throw new RuntimeException('This till already has an open session.');
            }

            $previousClosingBalance = $this->previousClosingBalance($tillId);
            $note = $note !== null && trim($note) !== '' ? trim($note) : null;

            if ($previousClosingBalance !== null
                && round($openingBalance - $previousClosingBalance, 2) !== 0.0
                && $note === null
            ) {
                throw new RuntimeException(
                    'The opening balance must match the previous closing balance ('
                    .number_format($previousClosingBalance, 2)
                    .') unless you provide a reason for the difference.'
                );
            }

            $session = PosSession::create([
                'branch_id' => $branchId,
                'till_id' => $tillId,
                'status_id' => $openId,
                'opening_balance' => $openingBalance,
                'opening_note' => $note,
                'opened_at' => now(),
                'opened_by' => Auth::id(),
            ]);

            AuditLog::record('pos_session.opened', $session, [
                'opening_balance' => (string) $openingBalance,
                'previous_closing_balance' => $previousClosingBalance !== null ? (string) $previousClosingBalance : null,
                'opening_note' => $note,
            ]);

            return $session;
        });
    }

    /**
     * The counted closing balance of the till's most recently closed session
     * — the amount its next session's opening balance must carry forward.
     */
    public function previousClosingBalance(int $tillId): ?float
    {
        $previousSession = PosSession::withoutGlobalScopes()
            ->where('till_id', $tillId)
            ->whereHas(
                'status',
                fn ($q) => $q->withoutGlobalScopes()->where('code', Status::SESSION_CLOSED)
            )
            ->whereNotNull('closing_balance')
            ->latest('closed_at')
            ->first();

        return $previousSession ? (float) $previousSession->closing_balance : null;
    }

    /**
     * Closes the session, recording the counted closing balance against the
     * expected cash (opening balance + every cash movement since) so any
     * variance is captured in the audit trail, not silently discarded.
     */
    public function close(PosSession $session, float $closingBalance, ?string $note = null): PosSession
    {
        return DB::transaction(function () use ($session, $closingBalance, $note) {
            $session->refresh();
            if (! $session->isOpen()) {
                throw new RuntimeException('Session is not open.');
            }

            $expected = $this->cashMovements->expectedBalance($session);
            $variance = round($closingBalance - $expected, 2);

            $session->update([
                'status_id' => Status::idOf(Status::CTX_POS_SESSION, Status::SESSION_CLOSED),
                'closing_balance' => $closingBalance,
                // Frozen as at close: recomputing later would drift if a
                // movement were ever backdated, and the whole point of a
                // reconciliation is that it records what was true at the time.
                'expected_balance' => $expected,
                'variance' => $variance,
                'close_note' => $note,
                'closed_at' => now(),
                'closed_by' => Auth::id(),
            ]);

            AuditLog::record('pos_session.closed', $session, [
                'closing_balance' => (string) $closingBalance,
                'expected_balance' => (string) $expected,
                'variance' => (string) $variance,
                'note' => $note,
            ]);

            return $session;
        });
    }

    /**
     * The full reconciliation picture for a session: what the drawer was
     * supposed to hold versus what was counted, broken down by movement type.
     * The basis of the close dialog, the session history detail page, and the
     * end-of-shift Z-report.
     *
     * @return array<string, float|int|null>
     */
    public function reconciliation(PosSession $session): array
    {
        $session->loadMissing('cashMovements');

        $salesCash = (float) $session->cashMovements->where('source', CashMovement::SOURCE_SALE)->sum('amount');
        $cashIns = (float) $session->cashMovements
            ->where('type', CashMovement::IN)
            ->where('source', CashMovement::SOURCE_MANUAL)
            ->sum('amount');
        $cashOuts = (float) $session->cashMovements
            ->where('type', CashMovement::OUT)
            ->where('source', CashMovement::SOURCE_MANUAL)
            ->sum('amount');
        $refunds = (float) $session->cashMovements->where('source', CashMovement::SOURCE_REFUND)->sum('amount');

        $expected = $session->expected_balance !== null
            ? (float) $session->expected_balance
            : $this->cashMovements->expectedBalance($session);

        $counted = $session->closing_balance !== null ? (float) $session->closing_balance : null;

        return [
            'opening_balance' => (float) $session->opening_balance,
            'sales_cash' => round($salesCash, 2),
            'cash_in' => round($cashIns, 2),
            'cash_out' => round($cashOuts, 2),
            'refunds' => round($refunds, 2),
            'expected_balance' => round($expected, 2),
            'counted_balance' => $counted !== null ? round($counted, 2) : null,
            'variance' => $counted !== null ? round($counted - $expected, 2) : null,
            'movement_count' => $session->cashMovements->count(),
        ];
    }
}
