<?php

namespace App\Domain\Financial\Services;

use App\Domain\Financial\Support\BundledProductLine;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

/**
 * Finding the files that need settling, instead of waiting for a parent to
 * complain at the desk.
 *
 * Every case below was read off a real academy's books before it was written
 * here. They are not errors in the sense of broken code — they are what happens
 * when a club that has been running on paper is entered into a system over two
 * evenings: a squad registered in a batch with the "pay now" toggle left off, a
 * registration card taken in three instalments and typed into a free-text line,
 * a month that was billed at zero because no price existed yet.
 *
 * Nothing here writes. It reports what a human then decides about, which is why
 * every case carries a suggestion rather than an action.
 */
class AccountAnomalyScanner
{
    /**
     * The catalogue of what can be wrong with an account.
     *
     * `severity` orders the worklist: money that was probably collected and
     * never recorded outranks money nobody ever chased.
     */
    public const CASES = [
        'no_enrollment' => [
            'icon' => 'no-symbol',
            'label' => 'لا يوجد له اشتراك مسجَّل',
            'hint' => 'لاعب نشط له فواتير سابقة ولا يوجد له أي سجل اشتراك — لا شيء في النظام يحاسبه.',
            'severity' => 0,
            'meaning' => 'اللاعب موجود ونشط وله فواتير، لكن لا يوجد في النظام أي سجل يربطه ببرنامج. سجل الاشتراك هو الشيء الوحيد الذي يبني عليه التجديد الشهري فواتيره، فبدونه لا تصدر له فاتورة أبداً، ولا يظهر في كشف المجموعة، ولا في تقرير المتأخرات. هو يتدرب، والحسابات لا تراه.',
            'cause' => 'حُذف البرنامج أو المجموعة التي كان مسجَّلاً بها. كان الحذف يمسح سجلات الاشتراك معه بلا رجعة، فيختفي اللاعب من الحسابات وهو ما زال في الملعب. لم يعد هذا ممكناً: حذف برنامج أو مجموعة بها اشتراكات مرفوض الآن.',
            'steps' => [
                'اعرف برنامجه من آخر فاتورة اشتراك — اسم البرنامج مكتوب في بند الفاتورة نفسه، ويظهر أسفل اسمه هنا.',
                'سجِّله في البرنامج المقابل من شاشة «تسجيل لاعب قائم».',
                'اضبط تاريخ التجديد على أول الشهر الجاري حتى تصدر له فاتورة هذا الشهر.',
                'إن كان قد فاته شهر أو أكثر، ستظهر له حالة «شهر بلا فاتورة» بعد التسجيل — عالجها من هنا.',
            ],
            'tool' => 'تسجيل لاعب قائم، ثم معالج التسوية للشهور الفائتة',
        ],
        'never_paid' => [
            'icon' => 'banknotes',
            'label' => 'لم يُسجَّل له أي دفع',
            'hint' => 'شهور انتهت ولم يُسجَّل عليها أي تحصيل — يُراجَع مع الاستقبال: هل حُصِّل نقداً؟ (الشهر الجاري لا يُحتسب)',
            'severity' => 1,
            'meaning' => 'كل فواتير هذا الحساب ما زالت بلا أي تحصيل، وبينها شهر منتهٍ على الأقل. لم يدخل على الحساب ولا قرش واحد في النظام منذ فتحه.',
            'cause' => 'في الغالب ليس امتناعاً عن الدفع. الأشيع: صف كامل أُدخل في جلسة واحدة وزر «الدفع الآن» مغلق افتراضياً، أو مبلغ حُصِّل نقداً في الاستقبال ولم يُسجَّل. الشهر الجاري لا يُحتسب هنا لأنه ما زال في وقت تحصيله.',
            'steps' => [
                'راجع دفتر الكاش أو إيصالات الاستقبال في تاريخ تسجيله.',
                'إن كان قد دفع فعلاً: سجِّل الدفعة بتاريخها الأصلي، لا بتاريخ اليوم — وإلا اختل تقرير الإيرادات الشهري.',
                'إن لم يدفع: تواصل مع ولي الأمر، أو سجِّل المبلغ إعفاءً بسبب مكتوب حتى لا يظل معلَّقاً على الحساب.',
            ],
            'tool' => 'معالج التسوية ← تسجيل دفعة، أو إعفاء',
        ],
        'handtyped_product' => [
            'icon' => 'document-text',
            'label' => 'منتج مكتوب كبند يدوي',
            'hint' => 'دفع مالاً لمنتج (قيد/شنطة/زي) كُتب كنص حر ولم يُربط بالمنتج — المخزون والتقارير لا تراه.',
            'severity' => 2,
            'meaning' => 'بند في فاتورته مكتوب بخط اليد باسم منتج تبيعه الأكاديمية، لكنه غير مربوط بالمنتج نفسه. المال دخل، لكن المخزون لم ينقص، وتقرير مبيعات المنتجات لا يعرف بهذه البيعة.',
            'cause' => 'أُضيف البند من خانة «بند حر» بدل بيع المنتج من نقطة البيع — غالباً لأن المنتج لم يكن مضافاً وقتها، أو لأن الإدخال كان أسرع.',
            'steps' => [
                'تأكد أن البند فعلاً يقصد هذا المنتج وليس شيئاً آخر بنفس الاسم.',
                'اربط البند بالمنتج من معالج التسوية — لا تلغِ الفاتورة ولا تعيد إصدارها.',
                'الربط يصحح المخزون والتقارير بأثر رجعي، ولا يغيّر قرشاً في الفاتورة.',
            ],
            'tool' => 'معالج التسوية ← ربط بند بمنتج',
        ],
        'zero_invoice' => [
            'icon' => 'receipt-percent',
            'label' => 'فاتورة بقيمة صفر',
            'hint' => 'صدرت فاتورة بلا سعر — الشهر لم يُحاسَب أصلاً.',
            'severity' => 3,
            'meaning' => 'فاتورة إجماليها صفر. الشهر يبدو مُحاسَباً في الكشوف بينما لم يُطلب فيه أي مبلغ، وهذا أخطر من شهر بلا فاتورة أصلاً لأن لا شيء ينبّه إليه.',
            'cause' => 'صدرت الفاتورة والبرنامج بلا سعر أساسي فعّال في ذلك التاريخ، أو بسعر ينطبق على فئة عضوية غير فئة اللاعب.',
            'steps' => [
                'راجع سعر البرنامج: هل يوجد سعر فعّال لفئة هذا اللاعب (عضو / غير عضو)؟ صحِّحه أولاً وإلا تكرر الأمر الشهر القادم.',
                'ثم صحِّح قيمة الفاتورة نفسها من معالج التسوية بالسعر الصحيح.',
                'إن كان الإعفاء مقصوداً، اكتب السبب في التسوية حتى لا يعود الحساب إلى هذه القائمة.',
            ],
            'tool' => 'شاشة الأسعار، ثم معالج التسوية ← تصحيح قيمة',
        ],
        'stacked_unpaid' => [
            'icon' => 'clock',
            'label' => 'متأخرات متراكمة',
            'hint' => 'شهران منتهيان فأكثر بلا تحصيل — يُراجَعان شهراً شهراً. اشتراك الشهر الجاري لا يُحتسب هنا.',
            'severity' => 4,
            'meaning' => 'شهران منتهيان أو أكثر ما زالا بلا تحصيل. بعكس «لم يُسجَّل له أي دفع»، هذا حساب يدفع أحياناً — وهو ما يجعل المتأخر فيه أقرب إلى نسيان منه إلى انقطاع.',
            'cause' => 'دفعات جزئية غير مكتملة، أو شهور مرّت وقت انشغال الاستقبال، أو ولي أمر يدفع كل شهرين ولا أحد يتابع.',
            'steps' => [
                'راجع الشهور شهراً شهراً — لا تحصِّل المجموع دفعة واحدة، فالتقارير الشهرية تُبنى على الشهر الذي تخصّه الدفعة.',
                'ما تأكد تحصيله نقداً: سجِّله بتاريخه.',
                'الباقي: اتفق على خطة أقساط بدل تركه متأخرات مفتوحة.',
            ],
            'tool' => 'معالج التسوية ← تسجيل دفعة / خطة أقساط',
        ],
        'unbilled_month' => [
            'icon' => 'calendar',
            'label' => 'شهر بلا فاتورة',
            'hint' => 'شهر بين بداية الاشتراك واليوم لم تصدر له فاتورة اشتراك.',
            'severity' => 5,
            'meaning' => 'شهر مرّ واللاعب مشترك فيه ولم تصدر له فاتورة أصلاً. المبلغ ليس متأخراً — هو غير مطلوب من الأساس، ولن يظهر في أي تقرير مديونية.',
            'cause' => 'توقّف التجديد التلقائي في ذلك الشهر، أو سُجِّل اللاعب بأثر رجعي بعد بدء الموسم، أو حُذف اشتراكه وأُعيد.',
            'steps' => [
                'تأكد أنه كان فعلاً متدرباً في ذلك الشهر — كشف الحضور يحسم هذا.',
                'إن كان متدرباً: أصدر فاتورة الشهر بتاريخه من معالج التسوية، وستُحتسب في إيراد شهرها لا في إيراد اليوم.',
                'إن لم يكن: لا تصدر شيئاً، وسجّل السبب حتى لا يظهر الشهر مرة أخرى.',
            ],
            'tool' => 'معالج التسوية ← فوترة شهر',
        ],
        'over_billed_bundle' => [
            'icon' => 'exclamation-triangle',
            'label' => 'مستلزم محاسَب بأكثر من سعره',
            'hint' => 'مجموع البنود المكتوبة يدوياً لهذا المستلزم يتجاوز سعره — غالباً قسط أُدخل مرتين، أو تصحيح أضاف فاتورة بدل أن يحل محل القديمة.',
            'severity' => 2,
            'meaning' => 'مجموع ما حُوسِب عليه لهذا المستلزم أكبر من سعره المعلن. هذه هي الحالة الوحيدة في القائمة التي تكون فيها الأكاديمية مخطئة في حق ولي الأمر لا العكس — الفارق مطلوب منه مرتين.',
            'cause' => 'قسط أُدخل مرتين، أو تصحيح أُضيف كفاتورة جديدة بدل أن يحل محل القديمة، فبقيت الاثنتان قائمتين.',
            'steps' => [
                'افتح الحساب واقرأ البنود: أيّها الأصل وأيّها التكرار؟ التاريخ والرقم يفصلان بينهما.',
                'ألغِ البند المكرر من معالج التسوية — الإلغاء يُسجَّل كقيد عكسي، ولا تُعدَّل الفاتورة الأصلية أبداً.',
                'إن كان قد دفع الزيادة فعلاً: أضفها إلى رصيده بدل ردّها نقداً، فتُخصم تلقائياً من الشهر القادم.',
            ],
            'tool' => 'معالج التسوية ← إلغاء بند مكرر / إضافة للرصيد',
        ],
        'missing_bundle' => [
            'icon' => 'cube',
            'label' => 'لم يُحاسَب على مستلزم البرنامج',
            'hint' => 'البرنامج يتطلب منتجاً (قيد اتحاد الكرة مثلاً) ولا يوجد له أي مبلغ.',
            'severity' => 6,
            'meaning' => 'برنامجه يشترط منتجاً — قيد اتحاد الكرة، شنطة، زي — ولا يوجد على حسابه أي مبلغ يخصّه، لا مدفوعاً ولا مطلوباً.',
            'cause' => 'سُجِّل قبل أن يُربط المنتج بالبرنامج، أو أُدخل من شاشة لا تبيع المستلزمات، أو استُثني شفهياً ولم يُكتب.',
            'steps' => [
                'تأكد أولاً أنه لم يدفعه خارج النظام — راجع الاستقبال قبل أن تطالبه بمبلغ ربما سدده.',
                'إن كان قد دفع: سجّل البيعة بتاريخها الأصلي حتى ينضبط المخزون.',
                'إن لم يدفع: بِع له المستلزم من هنا، أو ضعه على خطة أقساط.',
                'إن كان معفى: سجّل الإعفاء بسببه، ولا تتركه بلا قرار.',
            ],
            'tool' => 'معالج التسوية ← بيع مستلزم / خطة أقساط',
        ],
        'partial_bundle' => [
            'icon' => 'wallet',
            'label' => 'مستلزم مدفوع جزئياً',
            'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج وتأخر عن قسط شهر منتهٍ، أو سُجِّل دفعه خارج المنتج. الأقساط المنتظمة لا تظهر هنا.',
            'severity' => 6,
            'meaning' => 'دفع جزءاً من قيمة المستلزم والباقي متوقف. اللاعب الذي يسدد أقساطه في مواعيدها لا يظهر هنا إطلاقاً — الظاهر هنا إمّا متأخر عن قسط شهر انتهى، وإمّا مالٌ سُجِّل خارج المنتج فلا يعرف النظام أنه يخصّه.',
            'cause' => 'خطة أقساط توقفت، أو دفعات كُتبت كبنود حرة فلم تُحتسب على المنتج.',
            'steps' => [
                'اقرأ المدفوع والمتبقي في تفاصيل الصف قبل أي إجراء — الرقم هنا هو المرجع لا ذاكرة الاستقبال.',
                'إن كان الفارق مدفوعاً فعلاً وسُجِّل كبند حر: اربطه بالمنتج بدل تحصيله ثانية.',
                'إن كان متأخراً حقاً: حصِّل الباقي، أو أعِد جدولة الأقساط بموعد جديد.',
            ],
            'tool' => 'معالج التسوية ← ربط بند / تحصيل الباقي',
        ],
        'duplicate_person' => [
            'icon' => 'users',
            'label' => 'سجل مكرر',
            'hint' => 'يوجد مشترك آخر بنفس الاسم والهاتف — الفواتير موزعة على سجلين.',
            'severity' => 7,
            'meaning' => 'طفل واحد بسجلّين. فواتيره ومدفوعاته موزعة بينهما، فكل سجل يبدو ناقصاً، ولا واحد منهما يعرض الصورة الصحيحة لحسابه.',
            'cause' => 'سُجِّل مرتين — مرة في الاستقبال ومرة عبر التسجيل الذاتي، أو باسم مكتوب بصيغتين.',
            'steps' => [
                'قارن السجلين: تاريخ الميلاد ورقم ولي الأمر يؤكدان أنهما شخص واحد قبل أي دمج.',
                'اختر السجل الذي سيبقى — الأقدم عادةً، لأن رقم المشترك مطبوع على إيصالاته.',
                'حصِّل وسوِّ كل حساب على حدة أولاً، ثم أوقف السجل الزائد بسبب مكتوب حتى لا يُستخدم ثانية.',
            ],
            'tool' => 'معالج التسوية على كل سجل، ثم إيقاف السجل الزائد',
        ],
    ];

    /**
     * Cases that annotate an account but do not, on their own, mean somebody
     * has to look at it.
     */
    private const INFORMATIONAL = [];

    /** How far back a missing month is worth flagging. */
    private const MONTHS_BACK = 6;

    /**
     * Every participant with at least one case, worst first.
     *
     * @return array<int, array{participant:Participant, cases:array<int,string>,
     *     owed:int, paid:int, unpaid_invoices:int, severity:int, detail:array}>
     */
    public function scan(?int $branchId = null, ?string $only = null, int $limit = 300): array
    {
        $participants = Participant::query()
            ->with(['person', 'enrollments' => fn ($q) => $q->where('status', 'active')->with('program')])
            ->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
            ->get();

        if ($participants->isEmpty()) {
            return [];
        }

        $ids = $participants->pluck('id')->all();
        $money = $this->moneyByParticipant($ids);
        $handTyped = $this->handTypedProductLines($participants);
        $duplicates = $this->duplicateGroups($participants);
        $bundles = $this->bundleStatus($participants);
        $unbilled = $this->unbilledMonths($participants);
        $orphaned = $this->orphanedAccounts($participants);

        $rows = [];

        foreach ($participants as $participant) {
            $m = $money[$participant->id] ?? null;
            $cases = [];
            $detail = [];

            if ($m) {
                // Never a single piastre, and a month that has already ended
                // went uncollected: that is a file to ask the front desk about,
                // not a debtor to chase.
                if ($m['paid'] === 0 && $m['billed'] > 0 && $m['overdue'] > 0) {
                    $cases[] = 'never_paid';
                }
                if ($m['overdue'] >= 2) {
                    $cases[] = 'stacked_unpaid';
                }
                if ($m['zero_invoices'] > 0) {
                    $cases[] = 'zero_invoice';
                    $detail['zero_invoices'] = $m['zero_invoices'];
                }
            }

            if (! empty($handTyped[$participant->id])) {
                $cases[] = 'handtyped_product';
                $detail['handtyped'] = $handTyped[$participant->id];
            }

            $bundle = $bundles[$participant->id] ?? [];

            if ($bundle !== []) {
                $detail['bundle_status'] = $bundle;

                $missing = array_values(array_filter($bundle, fn ($b) => $b['status'] === 'missing'));
                $partial = array_values(array_filter($bundle, fn ($b) => $b['status'] === 'partial'));

                if ($missing !== []) {
                    $cases[] = 'missing_bundle';
                    $detail['missing_bundle'] = $missing;
                }

                // Part paid is not "never bought": it is a card being paid off,
                // and calling it missing is what made a boy who had handed over
                // 2,500 look like he had bought nothing.
                //
                // But a card on an agreed plan, sold properly through the till
                // and paid up to this month, is ordinary business and does not
                // belong on this list either. What is left is money recorded
                // outside the product, or a plan that has missed an instalment
                // from a month that has already ended.
                $partialAnomalies = array_values(array_filter(
                    $partial,
                    fn ($b) => ! $b['on_schedule'] && ($b['from_text'] || $b['paid'] === 0)
                ));

                if ($partial !== []) {
                    $detail['partial_bundle'] = $partial;
                }

                if ($partialAnomalies !== []) {
                    $cases[] = 'partial_bundle';
                }

                // Billed more than the card costs. The excess is on the books
                // whatever else is true of the account, so this is checked
                // against every bundle row, not only the unpaid ones.
                $overBilled = array_values(array_filter($bundle, fn ($b) => ($b['over_billed'] ?? 0) > 0));

                if ($overBilled !== []) {
                    $cases[] = 'over_billed_bundle';
                    $detail['over_billed_bundle'] = $overBilled;
                }
            }

            if (! empty($unbilled[$participant->id])) {
                $cases[] = 'unbilled_month';
                $detail['unbilled_months'] = $unbilled[$participant->id];
            }

            if (! empty($duplicates[$participant->id])) {
                $cases[] = 'duplicate_person';
                $detail['duplicate_of'] = $duplicates[$participant->id];
            }

            if (! empty($orphaned[$participant->id])) {
                $cases[] = 'no_enrollment';
                $detail['no_enrollment'] = $orphaned[$participant->id];
            }

            if ($cases === []) {
                continue;
            }

            // Cases that only annotate an account never summon it here on
            // their own. Nothing is in that list today: a card the programme
            // requires and nobody was charged for is exactly what this screen
            // is meant to surface.
            if (! $only && self::INFORMATIONAL !== [] && array_diff($cases, self::INFORMATIONAL) === []) {
                continue;
            }

            if ($only && ! in_array($only, $cases, true)) {
                continue;
            }

            $rows[] = [
                'participant' => $participant,
                'cases' => $cases,
                'owed' => $m['owed'] ?? 0,
                'paid' => $m['paid'] ?? 0,
                'unpaid_invoices' => $m['unpaid'] ?? 0,
                'severity' => min(array_map(fn ($c) => self::CASES[$c]['severity'], $cases)),
                'detail' => $detail,
            ];
        }

        usort($rows, function ($a, $b) {
            return [$a['severity'], -$a['owed']] <=> [$b['severity'], -$b['owed']];
        });

        return array_slice($rows, 0, $limit);
    }

    /**
     * One participant's full picture, for the wizard's diagnosis step: every
     * invoice with what is left on it, the free-text lines that look like a
     * product, the months nobody billed, and the bundle they were never charged
     * for.
     */
    public function forParticipant(Participant $participant): array
    {
        $money = $this->moneyByParticipant([$participant->id])[$participant->id] ?? [
            'billed' => 0, 'paid' => 0, 'owed' => 0, 'unpaid' => 0,
            'overdue' => 0, 'overdue_amount' => 0, 'zero_invoices' => 0,
        ];

        $collection = collect([$participant->loadMissing(['person', 'enrollments.program'])]);

        return [
            'money' => $money,
            'handtyped' => $this->handTypedProductLines($collection)[$participant->id] ?? [],
            'bundle_status' => $bundle = $this->bundleStatus($collection)[$participant->id] ?? [],
            'missing_bundle' => array_values(array_filter($bundle, fn ($b) => $b['status'] === 'missing')),
            'partial_bundle' => array_values(array_filter($bundle, fn ($b) => $b['status'] === 'partial')),
            'over_billed' => array_values(array_filter($bundle, fn ($b) => ($b['over_billed'] ?? 0) > 0)),
            'unbilled_months' => $this->unbilledMonths($collection)[$participant->id] ?? [],
            'duplicate_of' => $this->duplicateGroups($collection)[$participant->id] ?? [],
            'no_enrollment' => $this->orphanedAccounts($collection)[$participant->id] ?? null,
        ];
    }

    // ---- the individual probes -------------------------------------------

    /**
     * @param array<int,int> $ids
     * @return array<int, array{billed:int, paid:int, owed:int, unpaid:int, zero_invoices:int}>
     */
    private function moneyByParticipant(array $ids): array
    {
        $rows = DB::table('invoices')
            ->where('billable_type', Participant::class)
            ->whereIn('billable_id', $ids)
            ->whereNull('deleted_at')
            ->where('status', '!=', 'cancelled')
            ->groupBy('billable_id')
            ->select(
                'billable_id',
                DB::raw('SUM(total_amount) as billed'),
                DB::raw('SUM(paid_amount) as paid'),
                DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN total_amount - paid_amount ELSE 0 END) as owed'),
                DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN 1 ELSE 0 END) as unpaid'),
                // Late means a month that has already ENDED and was not paid.
                //
                // Not "past its due date": this month's subscription is due on
                // the 8th and collected all month, and a club does not consider
                // a player a problem on the 9th. Anything still open from the
                // month we are in is the collect-payment screen's business, and
                // putting it here buries the files that need a decision.
                //
                // The month an invoice pays for is the one it names in metadata
                // (the renewal job and the settlement wizard both write it),
                // falling back to the month it was issued in.
                DB::raw("SUM(CASE WHEN total_amount > paid_amount AND COALESCE(metadata->>'month', to_char(issue_date, 'YYYY-MM')) < to_char(CURRENT_DATE, 'YYYY-MM') THEN 1 ELSE 0 END) as overdue"),
                DB::raw("SUM(CASE WHEN total_amount > paid_amount AND COALESCE(metadata->>'month', to_char(issue_date, 'YYYY-MM')) < to_char(CURRENT_DATE, 'YYYY-MM') THEN total_amount - paid_amount ELSE 0 END) as overdue_amount"),
                DB::raw('SUM(CASE WHEN total_amount = 0 THEN 1 ELSE 0 END) as zero_invoices'),
            )
            ->get();

        $out = [];
        foreach ($rows as $r) {
            $out[(int) $r->billable_id] = [
                'billed' => (int) $r->billed,
                'paid' => (int) $r->paid,
                'owed' => (int) $r->owed,
                'unpaid' => (int) $r->unpaid,
                'overdue' => (int) $r->overdue,
                'overdue_amount' => (int) $r->overdue_amount,
                'zero_invoices' => (int) $r->zero_invoices,
            ];
        }

        return $out;
    }

    /**
     * Free-text lines that name a product the academy sells.
     *
     * Same reading BundledProductLine gives the roster: whole normalised words,
     * matched against the words that identify the product and are not also a
     * programme's.
     *
     * @return array<int, array<int, array{invoice_item_id:int, invoice_number:string,
     *     description:string, amount:int, product_id:int, product_name:string}>>
     */
    private function handTypedProductLines($participants): array
    {
        $ids = $participants->pluck('id')->all();
        if ($ids === []) {
            return [];
        }

        $academyIds = $participants->pluck('academy_id')->filter()->unique()->all();

        $products = DB::table('products')
            ->whereIn('academy_id', $academyIds)
            ->whereNull('deleted_at')
            ->get(['id', 'academy_id', 'name_ar']);

        if ($products->isEmpty()) {
            return [];
        }

        $programNames = DB::table('training_programs')
            ->whereIn('academy_id', $academyIds)
            ->whereNull('deleted_at')
            ->pluck('name_ar')
            ->filter()
            ->all();

        $identifying = [];
        foreach ($products as $product) {
            $identifying[$product->id] = BundledProductLine::identifyingWords(
                (string) $product->name_ar,
                $programNames
            );
        }

        $lines = DB::table('invoice_items')
            ->join('invoices', 'invoices.id', '=', 'invoice_items.invoice_id')
            ->where('invoices.billable_type', Participant::class)
            ->whereIn('invoices.billable_id', $ids)
            ->whereNull('invoices.deleted_at')
            ->where('invoices.status', '!=', 'cancelled')
            ->whereNull('invoice_items.itemable_type')
            ->get([
                'invoice_items.id as item_id',
                'invoice_items.description',
                'invoice_items.total_amount',
                'invoices.id as invoice_id',
                'invoices.number',
                'invoices.billable_id',
            ]);

        $out = [];
        foreach ($lines as $line) {
            foreach ($products as $product) {
                if (! BundledProductLine::matches($line->description, (string) $product->name_ar, $identifying[$product->id])) {
                    continue;
                }

                $out[(int) $line->billable_id][] = [
                    'invoice_item_id' => (int) $line->item_id,
                    'invoice_id' => (int) $line->invoice_id,
                    'invoice_number' => (string) $line->number,
                    'description' => (string) $line->description,
                    'amount' => (int) $line->total_amount,
                    'product_id' => (int) $product->id,
                    'product_name' => (string) $product->name_ar,
                ];
                break;
            }
        }

        return $out;
    }

    /**
     * Where each participant stands on the products their programme requires.
     *
     * This is the probe that was wrong, and wrong in the way that matters most:
     * it asked whether a product line existed, so a boy who paid 2,500 toward
     * his federation card — typed into a free-text line on an invoice he shares
     * with his kit — was reported as never having bought one. That is precisely
     * the reading the group roster stopped doing, and having two answers to the
     * same question on two screens is worse than either answer alone.
     *
     * So it reads the money the same way BundledProductLine does, including the
     * bare "القسط الاول" when the programme requires exactly one product and
     * there is therefore only one thing it can be paying off. What comes back is
     * a position, not a yes/no: paid in full, part paid, or nothing at all.
     *
     * @return array<int, array<int, array{product_id:int, product_name:string,
     *     expected:int, billed:int, paid:int, from_text:bool, inferred:bool, status:string}>>
     */
    private function bundleStatus($participants): array
    {
        if (! DB::getSchemaBuilder()->hasTable('program_products')) {
            return [];
        }

        $programIds = [];
        foreach ($participants as $participant) {
            foreach ($participant->enrollments as $enrollment) {
                $programIds[] = $enrollment->training_program_id;
            }
        }

        $programIds = array_values(array_unique(array_filter($programIds)));
        if ($programIds === []) {
            return [];
        }

        $bundles = DB::table('program_products')
            ->whereIn('training_program_id', $programIds)
            ->where('is_required', true)
            ->get(['training_program_id', 'product_id']);

        if ($bundles->isEmpty()) {
            return [];
        }

        $byProgram = [];
        foreach ($bundles as $bundle) {
            $byProgram[$bundle->training_program_id][] = (int) $bundle->product_id;
        }

        $productIds = $bundles->pluck('product_id')->unique()->all();
        $products = DB::table('products')
            ->whereIn('id', $productIds)
            ->get(['id', 'name_ar', 'selling_price', 'member_price', 'non_member_price'])
            ->keyBy('id');

        $billing = app(ParticipantBillingService::class);
        $ids = $participants->pluck('id')->all();

        // A bare instalment can only be attributed where there is one thing it
        // could be paying off — the same rule the roster applies.
        $singleRequirementPrograms = array_keys(array_filter($byProgram, fn ($ps) => count($ps) === 1));

        // A card being paid on an agreed plan is only a problem when an
        // instalment from a month that has ended was never collected. The plan
        // rows live on the invoices that carry the product.
        $plans = DB::table('payment_plans')
            ->join('invoices', 'invoices.id', '=', 'payment_plans.invoice_id')
            ->where('invoices.billable_type', Participant::class)
            ->whereIn('invoices.billable_id', $participants->pluck('id')->all())
            ->whereNull('invoices.deleted_at')
            ->whereIn('payment_plans.status', ['active', 'completed'])
            ->get([
                'invoices.billable_id',
                'payment_plans.status',
                'payment_plans.next_due_date',
                'payment_plans.paid_installments',
                'payment_plans.total_installments',
            ])
            ->groupBy('billable_id');

        $currentMonthStart = now()->startOfMonth()->toDateString();

        $facts = [];
        foreach ($productIds as $productId) {
            $product = $products[$productId] ?? null;
            if (! $product) {
                continue;
            }

            $facts[$productId] = $billing->bundledProductForParticipants(
                $ids,
                (int) $productId,
                (string) $product->name_ar,
                null,
                true,
            );
        }

        $out = [];
        foreach ($participants as $participant) {
            if ($participant->is_free) {
                continue;
            }

            foreach ($participant->enrollments as $enrollment) {
                $single = in_array($enrollment->training_program_id, $singleRequirementPrograms, true);

                foreach ($byProgram[$enrollment->training_program_id] ?? [] as $productId) {
                    $product = $products[$productId] ?? null;
                    if (! $product) {
                        continue;
                    }

                    $row = $facts[$productId][$participant->id] ?? null;

                    // Money attributed only by an unnamed instalment counts when
                    // the programme requires one product; otherwise it is a guess.
                    if ($row && ! empty($row['inferred']) && ! $single) {
                        $row = null;
                    }

                    $billed = (int) ($row['billed'] ?? 0);
                    $paid = (int) ($row['paid'] ?? 0);

                    $tier = $participant->membership_type?->value ?? 'non_member';
                    $price = $tier === 'member'
                        ? ($product->member_price ?? $product->selling_price)
                        : ($product->non_member_price ?? $product->selling_price);

                    $isRealSale = ! empty($row['from_product_lines']);

                    // What the card actually obliges him to pay.
                    //
                    // A real product sale froze the agreed price at the till, so
                    // what it billed IS the obligation. Hand-typed lines are
                    // different in kind: each one is an instalment *toward* a
                    // card whose price the product record still holds, and the
                    // card is what is owed — not the sum of whatever anyone
                    // typed. Taking max(billed, price) meant every duplicate and
                    // every correction raised the debt: a boy billed 2,500 twice
                    // for the same first instalment and then 5,500 for "the
                    // rest" was told he owed 10,500 for an 8,000 card, and the
                    // "تسجيل باقي القيمة" button offered to bill him the
                    // inflated remainder again.
                    $expected = match (true) {
                        $isRealSale => $billed,
                        (int) $price > 0 => (int) $price,
                        default => $billed,
                    };

                    // Typed lines adding up to more than the card ever cost are
                    // not a larger obligation — they are the same money entered
                    // twice, and that is its own thing to go and look at.
                    $overBilled = $isRealSale ? 0 : max(0, $billed - $expected);

                    $status = match (true) {
                        $billed === 0 && $paid === 0 => 'missing',
                        $paid >= $expected && $expected > 0 => 'paid',
                        default => 'partial',
                    };

                    // On schedule: there is a plan, and its next instalment is
                    // not yet a month that has ended. A player paying his card
                    // in three goes, on time, is not an anomaly — and the
                    // operator does not want to see him at all.
                    $onSchedule = false;
                    foreach ($plans[$participant->id] ?? [] as $plan) {
                        if ($plan->status === 'completed'
                            || ! $plan->next_due_date
                            || substr((string) $plan->next_due_date, 0, 10) >= $currentMonthStart) {
                            $onSchedule = true;
                            break;
                        }
                    }

                    $out[$participant->id][$productId] = [
                        'product_id' => (int) $productId,
                        'product_name' => (string) $product->name_ar,
                        'expected' => $expected,
                        'billed' => $billed,
                        'paid' => $paid,
                        'over_billed' => $overBilled,
                        'price' => (int) $price,
                        'from_text' => (bool) ($row['from_text'] ?? false),
                        'inferred' => (bool) ($row['inferred'] ?? false),
                        'on_schedule' => $onSchedule,
                        'status' => $status,
                    ];
                }
            }
        }

        return array_map('array_values', $out);
    }

    /**
     * Months between the enrolment starting and today that carry no invoice.
     *
     * A month counts as billed when an invoice names it in metadata (the
     * renewal job and the settlement wizard both write that) or was simply
     * issued inside it. Only the last few months are looked at: a club that
     * started on paper in 2019 does not need a hundred flags.
     *
     * @return array<int, array<int, array{month:string, label:string, enrollment_id:int, program:string}>>
     */
    private function unbilledMonths($participants): array
    {
        $ids = $participants->pluck('id')->all();
        if ($ids === []) {
            return [];
        }

        $invoices = DB::table('invoices')
            ->where('billable_type', Participant::class)
            ->whereIn('billable_id', $ids)
            ->whereNull('deleted_at')
            ->where('status', '!=', 'cancelled')
            ->get(['billable_id', 'issue_date', 'metadata']);

        $billedMonths = [];
        foreach ($invoices as $invoice) {
            $pid = (int) $invoice->billable_id;
            $billedMonths[$pid][substr((string) $invoice->issue_date, 0, 7)] = true;

            $meta = json_decode((string) $invoice->metadata, true);
            if (is_array($meta) && ! empty($meta['month'])) {
                $billedMonths[$pid][(string) $meta['month']] = true;
            }
        }

        $floor = now()->copy()->startOfMonth()->subMonths(self::MONTHS_BACK);
        $out = [];

        foreach ($participants as $participant) {
            if ($participant->is_free) {
                continue;
            }

            foreach ($participant->enrollments as $enrollment) {
                // start_date is copied from the GROUP's season start
                // (EnrollmentService), so a player entered in August carries a
                // 16 July start and would be reported as owing July and part of
                // a season he was not in. The month he actually joined is the
                // later of the two dates.
                $start = Carbon::parse($enrollment->start_date ?? $enrollment->enrollment_date ?? now());
                $joined = Carbon::parse($enrollment->enrollment_date ?? $enrollment->start_date ?? now());
                $start = $start->max($joined)->startOfMonth();

                if ($start->lt($floor)) {
                    $start = $floor->copy();
                }

                // The current month is still being collected; it is not late.
                $end = now()->copy()->startOfMonth()->subMonth();

                for ($cursor = $start->copy(); $cursor->lte($end); $cursor->addMonth()) {
                    $key = $cursor->format('Y-m');
                    if (! empty($billedMonths[$participant->id][$key])) {
                        continue;
                    }

                    $out[$participant->id][] = [
                        'month' => $key,
                        'label' => $cursor->translatedFormat('F Y'),
                        'enrollment_id' => (int) $enrollment->id,
                        'program' => $enrollment->program?->name_ar ?? '—',
                    ];
                }
            }
        }

        return $out;
    }

    /**
     * Paying players who have invoice history and no enrolment row at all.
     *
     * Every other case on this screen is an account whose numbers are wrong.
     * This one is an account the system has stopped counting: renewal billing
     * reads `enrollments` and nothing else, so a player with no row there is
     * not billed late — he is not billed at all, and no report anywhere lists
     * him, which is what makes this the one case nobody can find by looking.
     *
     * It is on this screen because it was invisible on every other one. Deleting
     * a programme used to force-delete its enrolments; OC-Sport lost 181 of them
     * on 28 August 2026 and 95 paying players went a month unbilled while the
     * renewal run reported a clean success every night.
     *
     * The existence check drops the branch scope deliberately. A transfer can
     * leave a player enrolled in another branch, and telling an operator to
     * re-enrol somebody who is already enrolled is a worse failure than missing
     * a case — this list is only useful while every row on it is real.
     *
     * @return array<int, array{invoices:int, last_program:?string, last_month:?string}>
     */
    private function orphanedAccounts($participants): array
    {
        $candidates = $participants->filter(
            fn ($p) => ! $p->is_free && in_array($p->status?->value ?? $p->status, ['active', 'registered'], true)
        );

        if ($candidates->isEmpty()) {
            return [];
        }

        $ids = $candidates->pluck('id')->all();

        $enrolled = Enrollment::withoutBranchScope()
            ->whereIn('participant_id', $ids)
            ->distinct()
            ->pluck('participant_id')
            ->all();

        $orphanIds = array_values(array_diff($ids, $enrolled));

        if ($orphanIds === []) {
            return [];
        }

        // The subscription line on their last invoice is the only surviving
        // record of which programme they were in — the enrolment that knew is
        // gone. It is what the operator needs to put them back, so it is read
        // here rather than left for them to dig out invoice by invoice.
        $invoices = DB::table('invoices')
            ->leftJoin('invoice_items', function ($join) {
                $join->on('invoice_items.invoice_id', '=', 'invoices.id')
                    ->whereNull('invoice_items.itemable_type');
            })
            ->where('invoices.billable_type', Participant::class)
            ->whereIn('invoices.billable_id', $orphanIds)
            ->whereNull('invoices.deleted_at')
            ->where('invoices.status', '!=', 'cancelled')
            ->orderBy('invoices.issue_date')
            ->get([
                'invoices.id as invoice_id',
                'invoices.billable_id',
                'invoices.issue_date',
                'invoice_items.description',
            ]);

        $out = [];
        $seen = [];

        foreach ($invoices as $invoice) {
            $pid = (int) $invoice->billable_id;

            // The join fans out one row per line, so the invoice count is over
            // distinct invoice ids — otherwise a three-line invoice reads as
            // three invoices and the operator distrusts the whole row.
            $seen[$pid][(int) $invoice->invoice_id] = true;

            $out[$pid] ??= ['invoices' => 0, 'last_program' => null, 'last_month' => null];
            $out[$pid]['invoices'] = count($seen[$pid]);
            $out[$pid]['last_month'] = substr((string) $invoice->issue_date, 0, 7);

            $description = trim((string) $invoice->description);

            if ($description !== '') {
                // "تجديد اشتراك: فريق 2017" and "اشتراك: فريق 2017" both name the
                // programme; the prefix is the invoice's wording, not part of it.
                $out[$pid]['last_program'] = trim(preg_replace(
                    '/^(تجديد اشتراك|اشتراك)\s*:\s*/u',
                    '',
                    $description
                ));
            }
        }

        // No invoice, no evidence anything was ever billed: a half-finished
        // registration is somebody else's problem, not a settlement.
        return array_filter($out, fn ($row) => $row['invoices'] > 0);
    }

    /**
     * Two participant records for one child: same name, same guardian phone.
     *
     * @return array<int, array<int, array{id:int, name:string}>>
     */
    private function duplicateGroups($participants): array
    {
        $names = $participants->map(fn ($p) => $p->person?->name_ar)->filter()->unique()->values();
        if ($names->isEmpty()) {
            return [];
        }

        $rows = DB::table('participants')
            ->join('people', 'people.id', '=', 'participants.person_id')
            ->whereIn('people.name_ar', $names->all())
            ->whereNull('participants.deleted_at')
            ->get(['participants.id', 'people.name_ar', 'people.phone']);

        $groups = [];
        foreach ($rows as $row) {
            $key = $row->name_ar . '|' . ($row->phone ?? '');
            $groups[$key][] = ['id' => (int) $row->id, 'name' => (string) $row->name_ar];
        }

        $out = [];
        foreach ($groups as $members) {
            if (count($members) < 2) {
                continue;
            }

            foreach ($members as $member) {
                $out[$member['id']] = array_values(array_filter(
                    $members,
                    fn ($other) => $other['id'] !== $member['id']
                ));
            }
        }

        return $out;
    }
}
