<?php

namespace App\Console\Commands;

use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Support\BillingCycle;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Bill every active enrolment for each subscription cycle it owes.
 *
 * The academy's rule: every player renews on the 1st of the month, and every
 * already-registered player owes an invoice from the 1st for the full
 * programme subscription. This command is the only thing that makes that true
 * on its own, so its failure modes matter more than its happy path.
 *
 * Three properties are non-negotiable here, and each one is a bug that already
 * happened:
 *
 *  1. **It must not fail silently.** The previous version resolved the invoice
 *     actor with `User::find($enrollment->created_by)`. `enrollments` has no
 *     `created_by` column — it is `enrolled_by` — and Eloquent answers null for
 *     an attribute that does not exist, so `User::find(null)` returned null and
 *     every single enrolment hit the `continue`. The command still printed a
 *     summary and still returned SUCCESS. OC-Sport went a whole billing month
 *     with 230 players due and zero invoices raised, and nothing anywhere said
 *     so. A run that bills none of the players it found is now a FAILURE exit.
 *
 *  2. **A missed day must not cost a month.** The old code advanced
 *     `next_billing_date` by `addMonth()` from whatever it happened to be and
 *     only ever raised one invoice, so a run lost to a container restart at
 *     07:00 skipped that cycle permanently. Cycles are now caught up: the
 *     command bills every cycle between `next_billing_date` and today, each
 *     invoice dated its own 1st.
 *
 *  3. **The money must land in the month it bought.** An invoice raised on the
 *     20th because the 1st was missed is still September's money. `issue_date`
 *     is the cycle start, never `now()`, and `metadata['month']` carries the
 *     cycle key — the highest-precedence signal SubscriptionLine has, above
 *     reading Arabic month names out of free text and above the issue date.
 *
 * Invoice creation and the `next_billing_date` advance share one transaction,
 * so a crash mid-run can neither double-bill nor lose a cycle.
 */
class GenerateRenewalInvoices extends Command
{
    protected $signature = 'enrollments:generate-renewals
                            {--dry-run : Show what would be billed without creating invoices}
                            {--max-catchup=3 : How many missed cycles one enrolment may be billed for in a single run}';

    protected $description = 'Bill every active enrolment for each subscription cycle it owes, dated the 1st of that cycle';

    private int $created = 0;
    private int $failed = 0;
    private int $alreadyBilled = 0;
    private int $waived = 0;
    private int $adopted = 0;
    private int $deferred = 0;
    private int $orphaned = 0;

    /** @var array<int, User|null> academy id => the user renewal invoices are attributed to */
    private array $academyActors = [];

    public function handle(InvoiceService $invoiceService, PricingService $pricingService): int
    {
        $dryRun = (bool) $this->option('dry-run');
        $cap = max(1, (int) $this->option('max-catchup'));
        $today = Carbon::today();

        // `next_billing_date` is deliberately NOT filtered in SQL. A row that is
        // active, paying and on a renewing programme but has a null billing date
        // is invisible to every renewal query in the system — it simply never
        // gets billed, forever, and nobody finds out. Loading them here is what
        // lets the command adopt them into the cycle instead of skipping them.
        $enrollments = Enrollment::query()
            ->where('status', EnrollmentStatus::Active)
            ->whereHas('program', function ($q) {
                $q->whereIn('renewal_policy', [
                    RenewalPolicy::AutoRenew->value,
                    RenewalPolicy::ManualRenew->value,
                ])->whereNotNull('billing_cycle');
            })
            ->whereHas('participant', fn ($q) => $q->where('is_free', false))
            ->with(['program', 'participant.person', 'group'])
            ->get();

        if ($enrollments->isEmpty()) {
            $this->info('No renewing enrolments found.');
            // Not an early exit any more. "Nothing to bill" and "every enrolment
            // was destroyed" produce the same empty set here, and the second one
            // is the emergency — it has to be the loudest line in the output,
            // not the quietest.
            $this->reportOrphanedPayers();

            return $this->orphaned > 0 ? self::FAILURE : self::SUCCESS;
        }

        $this->info("Checking {$enrollments->count()} renewing enrolment(s) against cycle {$today->format('Y-m')}.");

        foreach ($enrollments as $enrollment) {
            $this->process($enrollment, $today, $cap, $dryRun, $invoiceService, $pricingService);
        }

        $this->reportOrphanedPayers();

        return $this->report($dryRun);
    }

    /**
     * Name every paying player who is on no enrolment at all.
     *
     * The loop above can only bill what `enrollments` holds. A player whose
     * enrolment row is gone is therefore not "skipped" — the command never sees
     * them, every counter in the summary reads clean, and they keep training
     * unbilled until a parent asks why no invoice came. That is precisely what
     * happened at OC-Sport: the season's programmes were deleted and recreated
     * under new names on 28 August 2026, the delete took 181 enrolments with
     * them, and 95 paying players went through the whole of September with no
     * invoice while this command reported a clean run every day.
     *
     * GuardsEnrolmentDeletion now stops the cause. This stops the silence — the
     * two are a pair, because no guard catches the rows already lost, or the
     * next way somebody finds to lose them.
     *
     * Deliberately narrow: only a player with NO enrolment row whatsoever. A
     * player who left has a cancelled one and belongs off the cycle; counting
     * them here would bury the real cases in noise until nobody read the list.
     *
     * Orphans alongside enrolments that did bill are a warning, not a failure
     * exit: somebody has to sit down and re-enrol them, and a command that
     * exits non-zero every night until they do is a command whose exit code
     * stops meaning anything. Orphans with NO billable enrolment at all is the
     * other case entirely — see handle(), where it is a failure — because
     * "nothing to bill" and "every enrolment was destroyed" produce the same
     * empty set, and the players left over are what tells them apart.
     */
    private function reportOrphanedPayers(): void
    {
        // No withoutBranchScope() here, on purpose: this must report on exactly
        // the population the loop above tried to bill. Unscoping one and not
        // the other would list players from a branch this run never touched.
        $orphans = Participant::query()
            ->whereIn('status', ['active', 'registered'])
            ->where('is_free', false)
            ->whereDoesntHave('enrollments')
            ->whereHas('invoices')
            ->with('person')
            ->get()
            ->reject(fn (Participant $p) => app(BranchSettingsService::class)
                ->billingHandledExternally($p->branch_id));

        if ($orphans->isEmpty()) {
            return;
        }

        $this->orphaned = $orphans->count();

        $this->newLine();
        $this->warn("{$this->orphaned} paying player(s) have invoice history but no enrolment — nothing bills them:");

        foreach ($orphans as $participant) {
            $name = $participant->person?->name_ar ?? $participant->person?->name ?? '—';
            $number = $participant->participant_number ?: "#{$participant->id}";
            $this->line("  {$number}  {$name}");
        }

        $this->line('  Re-enrol them, or cancel them if they left. Until then they train unbilled.');

        Log::error('Renewal run found paying players with no enrolment', [
            'count' => $this->orphaned,
            'participant_ids' => $orphans->pluck('id')->all(),
        ]);
    }

    private function process(
        Enrollment $enrollment,
        Carbon $today,
        int $cap,
        bool $dryRun,
        InvoiceService $invoiceService,
        PricingService $pricingService
    ): void {
        $program = $enrollment->program;
        $participant = $enrollment->participant;

        if (!$program || !$participant) {
            $this->failed++;
            Log::error('Renewal skipped — enrolment has no programme or participant', [
                'enrollment_id' => $enrollment->id,
            ]);

            return;
        }

        // A branch whose money is handled outside the system bills nobody.
        // This guard has to come before the adoption below, not after: those
        // enrolments are created with no billing date precisely because they
        // are not on the cycle, and adoption would read that as an oversight
        // and put every one of them onto it.
        if (app(BranchSettingsService::class)->billingHandledExternally($enrollment->branch_id)) {
            return;
        }

        // An active payer with no billing date has never been on the cycle.
        // Adopt them from the CURRENT cycle forward — never retroactively,
        // because we have no evidence about months nobody ever billed them for.
        if (!$enrollment->next_billing_date) {
            $this->adopted++;
            $start = BillingCycle::startOf($today);
            $this->line("  [ADOPT] {$this->name($enrollment)} — had no billing date, joining cycle {$start->format('Y-m')}");

            if (!$dryRun) {
                $enrollment->update(['next_billing_date' => $start->toDateString()]);
            }

            $enrollment->next_billing_date = $start;
        }

        $cycles = BillingCycle::dueCycles(
            $enrollment->next_billing_date,
            $today,
            $program->billing_cycle,
            $program->program_duration_weeks,
            $cap
        );

        if (empty($cycles)) {
            return;
        }

        $remaining = BillingCycle::remainingAfter(
            $enrollment->next_billing_date,
            $today,
            $program->billing_cycle,
            $program->program_duration_weeks,
            $cap
        );

        if ($remaining > 0) {
            // Never let a cap read as "all done": say what was left, so a long
            // outage draining over several days is visible while it happens.
            $this->deferred += $remaining;
            $this->warn("  [DEFER] {$this->name($enrollment)} — {$remaining} older cycle(s) left for the next run");
        }

        foreach ($cycles as $cycleStart) {
            $this->bill($enrollment, $cycleStart, $dryRun, $invoiceService, $pricingService);
        }
    }

    private function bill(
        Enrollment $enrollment,
        Carbon $cycleStart,
        bool $dryRun,
        InvoiceService $invoiceService,
        PricingService $pricingService
    ): void {
        $program = $enrollment->program;
        $participant = $enrollment->participant;
        $monthKey = BillingCycle::monthKey($cycleStart);

        if ($this->alreadyInvoiced($enrollment, $monthKey)) {
            // The receptionist raised this cycle by hand from the payment
            // wizard before the cron got to it. Move the anchor on rather than
            // billing it twice.
            $this->alreadyBilled++;
            if (!$dryRun) {
                $this->advance($enrollment, $cycleStart);
            }

            return;
        }

        $branchId = $this->billingBranch($enrollment);

        if (!$branchId) {
            // An invoice filed against no branch is not a smaller problem than
            // an unbilled player, it is a quieter one: branch is an isolation
            // boundary now, so a null there belongs to no branch and appears on
            // no collection screen at all. The money would exist and nobody
            // could see it. Fail the same way an unpriceable programme does.
            $this->failed++;
            Log::error('Renewal invoice failed — no branch to file the invoice against', [
                'enrollment_id' => $enrollment->id,
                'participant_id' => $enrollment->participant_id,
                'program_id' => $program->id,
                'cycle' => $monthKey,
            ]);
            $this->error("  [FAIL] {$this->name($enrollment)} — no branch on the group, the player, the enrolment or the programme");

            return;
        }

        if ($dryRun) {
            $this->line("  [DRY] {$this->name($enrollment)} — {$program->name_ar} — cycle {$monthKey}");
            $this->created++;

            return;
        }

        try {
            // Price AS OF the cycle being billed, not as of today: a cycle
            // caught up in September must be charged September's price list,
            // and a rule that expired last week must not silently rewrite a
            // month it was live for.
            $priceResult = $pricingService->calculate(
                priceable: $program,
                participant: $participant,
                branchId: $branchId,
                date: $cycleStart->toDateString(),
            );
        } catch (DomainException $e) {
            // "No active base price" is a hard fail by design — never guess a
            // price. But a player we cannot price is a player we are not
            // billing, which is the exact silence this command exists to break.
            $this->failed++;
            Log::error('Renewal invoice failed — programme could not be priced', [
                'enrollment_id' => $enrollment->id,
                'program_id' => $program->id,
                'cycle' => $monthKey,
                'reason' => $e->getMessage(),
            ]);
            $this->error("  [FAIL] {$this->name($enrollment)} — {$program->name_ar}: {$e->getMessage()}");

            return;
        }

        if ($priceResult->finalAmount <= 0) {
            // A real zero (a full-value discount) is a waiver, not a bug, but
            // it is rare enough that it should be visible rather than inferred
            // from a missing invoice.
            $this->waived++;
            Log::info('Renewal waived — price resolved to zero', [
                'enrollment_id' => $enrollment->id,
                'cycle' => $monthKey,
            ]);
            $this->advance($enrollment, $cycleStart);

            return;
        }

        $actor = $this->actorFor($enrollment);

        if (!$actor) {
            $this->failed++;
            Log::error('Renewal invoice failed — no user exists to attribute the invoice to', [
                'enrollment_id' => $enrollment->id,
                'academy_id' => $enrollment->academy_id,
            ]);

            return;
        }

        try {
            DB::transaction(function () use ($enrollment, $program, $participant, $branchId, $priceResult, $cycleStart, $monthKey, $invoiceService, $actor) {
                $label = BillingCycle::monthLabel($cycleStart);

                $invoice = $invoiceService->create([
                    'academy_id' => $enrollment->academy_id ?? $program->academy_id,
                    'branch_id' => $branchId,
                    'billable_type' => $participant->getMorphClass(),
                    'billable_id' => $participant->id,
                    'number' => $invoiceService->generateNumber($program->academy_id),
                    'subtotal_amount' => $priceResult->baseAmount,
                    // The discount belongs on the header and nowhere else.
                    // recalculateTotals() computes total = sum(line totals) -
                    // header discount, so putting the same figure on the line
                    // as well subtracts it twice and undercharges the academy.
                    'discount_amount' => $priceResult->totalDiscount,
                    'total_amount' => $priceResult->finalAmount,
                    'tax_amount' => 0,
                    // Dated the cycle it buys, not the day the job ran. This is
                    // what puts the money in the right month when a run is late.
                    'issue_date' => $cycleStart->toDateString(),
                    'due_date' => $cycleStart->copy()->addDays(7)->toDateString(),
                    'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
                    'notes' => "تجديد اشتراك {$label} — {$program->name_ar}",
                    'metadata' => [
                        // SubscriptionLine trusts this above everything else,
                        // so the cycle survives however the text is worded.
                        'month' => $monthKey,
                        'renewal' => true,
                        'renewal_enrollment_id' => (string) $enrollment->id,
                    ],
                ], [
                    [
                        'description' => "اشتراك {$label}: {$program->name_ar}",
                        'quantity' => 1,
                        'unit_price' => $priceResult->baseAmount,
                        'discount_amount' => 0,
                        'tax_amount' => 0,
                    ],
                ], $actor);

                // Sent immediately, so it is collectable from the payment
                // wizard the moment it exists rather than sitting in draft.
                $invoice->update(['status' => InvoiceStatus::Sent]);

                $this->advance($enrollment, $cycleStart);

                $enrollment->update([
                    'last_billed_at' => $cycleStart->toDateString(),
                    'payment_status' => 'pending',
                ]);
            });

            $this->created++;
        } catch (\Throwable $e) {
            $this->failed++;
            Log::error('Renewal invoice failed', [
                'enrollment_id' => $enrollment->id,
                'cycle' => $monthKey,
                'error' => $e->getMessage(),
            ]);
            $this->error("  [FAIL] {$this->name($enrollment)} — {$e->getMessage()}");
        }
    }

    /**
     * Has this cycle already been raised for this enrolment?
     *
     * Both renewal producers stamp `metadata.month` and
     * `metadata.renewal_enrollment_id`, as strings so the comparison behaves the
     * same on Postgres and on the SQLite the tests run against. This is the
     * second line of defence only — the first is that the invoice and the
     * `next_billing_date` advance commit together.
     */
    private function alreadyInvoiced(Enrollment $enrollment, string $monthKey): bool
    {
        return Invoice::query()
            ->where('billable_type', $enrollment->participant->getMorphClass())
            ->where('billable_id', $enrollment->participant_id)
            ->where('status', '!=', InvoiceStatus::Cancelled->value)
            ->where('metadata->month', $monthKey)
            ->where('metadata->renewal_enrollment_id', (string) $enrollment->id)
            ->exists();
    }

    /**
     * Move the anchor to the cycle after the one just settled.
     *
     * Advancing from the CYCLE rather than from `now()` is the whole point: a
     * cycle billed three weeks late still hands over to the 1st of the next
     * one, so lateness never compounds.
     */
    private function advance(Enrollment $enrollment, Carbon $cycleStart): void
    {
        $program = $enrollment->program;

        $next = BillingCycle::next(
            $cycleStart,
            $program?->billing_cycle,
            $program?->program_duration_weeks
        );

        $enrollment->update(['next_billing_date' => $next->toDateString()]);
        $enrollment->next_billing_date = $next;
    }

    /**
     * Who the invoice is attributed to.
     *
     * Prefer the person who actually enrolled the player, because that is the
     * honest audit answer. Fall back rather than skip: a receptionist leaving
     * the academy must never be the reason a paying member goes unbilled — that
     * trade-off, made the other way round, is what caused the outage this
     * command's docblock describes.
     */
    private function actorFor(Enrollment $enrollment): ?User
    {
        if ($enrollment->enrolled_by && $actor = User::find($enrollment->enrolled_by)) {
            return $actor;
        }

        if ($enrollment->program?->created_by && $actor = User::find($enrollment->program->created_by)) {
            return $actor;
        }

        $academyId = $enrollment->academy_id ?? $enrollment->program?->academy_id;

        return $this->academyActors[$academyId] ??= User::query()
            ->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
            ->orderByDesc('is_super_admin')
            ->orderBy('id')
            ->first()
            ?? User::query()->orderBy('id')->first();
    }

    /**
     * The branch this subscription is billed at.
     *
     * Order is evidence, strongest first:
     *
     *  - the group, because that is where the training is actually delivered;
     *  - the player, because that is what every other invoice-raising path
     *    resolves to (InvoiceService::resolveBranchId), so a cron-raised
     *    invoice files where a desk-raised one would;
     *  - the enrolment's own stamp, for a programme-only enrolment with no
     *    group;
     *  - the programme last, and only when it names one: a NULL there means
     *    "every branch sells this", which is a catalogue fact and never
     *    evidence of where the money was earned.
     */
    private function billingBranch(Enrollment $enrollment): ?int
    {
        $branchId = $enrollment->group?->branch_id
            ?? $enrollment->participant?->branch_id
            ?? $enrollment->branch_id
            ?? $enrollment->program?->branch_id;

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

    private function name(Enrollment $enrollment): string
    {
        return $enrollment->participant?->person?->name_ar
            ?? $enrollment->participant?->person?->name
            ?? "#{$enrollment->participant_id}";
    }

    /**
     * A run that found players to bill and billed none of them is a failure,
     * not a quiet success. That distinction is the whole reason a broken
     * command could run daily for a month without anyone noticing.
     */
    private function report(bool $dryRun): int
    {
        $verb = $dryRun ? 'Would create' : 'Created';
        $this->info("{$verb} {$this->created} renewal invoice(s).");

        foreach ([
            'already billed this cycle' => $this->alreadyBilled,
            'adopted onto the cycle' => $this->adopted,
            'waived (price resolved to zero)' => $this->waived,
            'older cycles deferred to the next run' => $this->deferred,
            'paying players with NO enrolment (billed by nothing)' => $this->orphaned,
            'FAILED' => $this->failed,
        ] as $label => $count) {
            if ($count > 0) {
                $this->line("  {$count} {$label}");
            }
        }

        if ($this->failed > 0) {
            Log::error('Renewal run finished with failures', [
                'created' => $this->created,
                'failed' => $this->failed,
            ]);

            return self::FAILURE;
        }

        return self::SUCCESS;
    }
}
