<?php

namespace App\Domain\Training\Support;

use Illuminate\Support\Carbon;

/**
 * When a subscription renewal falls due.
 *
 * The academy's rule is one sentence and admits no exceptions: **every renewal
 * is due on the 1st of a month.** A player who joins on the 14th still renews
 * on the 1st, and a cycle that is billed late is still billed *for* the 1st —
 * the money belongs to the month it bought, not to the day the job got round
 * to it.
 *
 * That rule used to be written out five separate times — in EnrollmentService,
 * in the renewal command, in CollectPaymentWizard, in ReconciliationWizard and
 * in RetroactiveEnrollmentWizard — and they disagreed. The renewal command
 * advanced `$current->addMonth()`, so a cycle billed one day late moved the
 * anchor one day later *permanently*; the reconciliation wizard advanced from
 * `now()` rather than from the cycle it was closing, which silently skipped
 * one. This class exists so the rule has exactly one implementation and every
 * caller is provably on it.
 *
 * Every date returned here is the 1st of some month. No database, no state.
 */
final class BillingCycle
{
    /**
     * How many whole months one cycle lasts, per `training_programs.billing_cycle`.
     *
     * `per_duration` prices a fixed-length course rather than a month, but it
     * still renews on a 1st: its length is rounded UP to whole months, so a
     * 12-week course bills quarterly and an 6-week one bills every two months.
     * Rounding up rather than down is deliberate — billing a player before the
     * period they paid for has run out is the worse error.
     */
    public static function lengthInMonths(?string $cycle, ?int $programDurationWeeks = null): int
    {
        return match ($cycle) {
            'quarterly' => 3,
            'semi_annual' => 6,
            'annual' => 12,
            'per_duration' => max(1, (int) ceil(($programDurationWeeks ?: 4) / 4)),
            default => 1,
        };
    }

    /**
     * The start of the cycle that contains $date — i.e. the 1st of its month.
     * Defaults to the cycle we are living in right now.
     */
    public static function startOf(Carbon|string|null $date = null): Carbon
    {
        return self::carbon($date)->startOfMonth();
    }

    /**
     * The cycle after the one containing $from.
     *
     * Anchored to the month, never to the day: advancing from the 14th and
     * advancing from the 1st give the same answer, which is what stops the
     * billing day drifting a little further every time a run is late.
     */
    public static function next(Carbon|string|null $from = null, ?string $cycle = null, ?int $programDurationWeeks = null): Carbon
    {
        return self::startOf($from)->addMonthsNoOverflow(self::lengthInMonths($cycle, $programDurationWeeks));
    }

    /**
     * 'YYYY-MM' for the cycle containing $date.
     *
     * This is the key stamped into `invoices.metadata['month']`, which is the
     * most trustworthy signal SubscriptionLine has for deciding which month an
     * invoice's money belongs to — it beats reading month names out of Arabic
     * free text, and it beats the issue date.
     */
    public static function monthKey(Carbon|string|null $date = null): string
    {
        return self::startOf($date)->format('Y-m');
    }

    /**
     * Arabic month name for a cycle, so the invoice line says out loud which
     * month it is for instead of leaving the reader to infer it from a date.
     */
    public static function monthLabel(Carbon|string|null $date = null): string
    {
        $start = self::startOf($date);

        $names = [
            1 => 'يناير', 2 => 'فبراير', 3 => 'مارس', 4 => 'أبريل',
            5 => 'مايو', 6 => 'يونيو', 7 => 'يوليو', 8 => 'أغسطس',
            9 => 'سبتمبر', 10 => 'أكتوبر', 11 => 'نوفمبر', 12 => 'ديسمبر',
        ];

        return $names[(int) $start->month] . ' ' . $start->year;
    }

    /**
     * Every cycle a player owes as of $today, oldest first — the catch-up.
     *
     * A missed run must not cost the academy a month. If the container was
     * restarting at 07:00 on the 1st, or a deploy swallowed the run, or the
     * command crashed, the next run bills the cycles that were skipped, each
     * one dated its own 1st. Nothing is "too late" to bill; it is only ever
     * billed for the right month.
     *
     * $cap bounds ONE run, not the backlog: whatever is left over is picked up
     * by the next run, so a long outage drains over several days rather than
     * dumping a year of invoices on a parent at once. The caller is expected to
     * report what it left behind — a silent cap reads as "all done".
     *
     * @return array<int, Carbon> cycle start dates, each the 1st of a month
     */
    public static function dueCycles(
        Carbon|string $nextBillingDate,
        Carbon|string|null $today = null,
        ?string $cycle = null,
        ?int $programDurationWeeks = null,
        int $cap = 3
    ): array {
        $length = self::lengthInMonths($cycle, $programDurationWeeks);
        $cursor = self::startOf($nextBillingDate);
        $currentCycle = self::startOf($today);

        $due = [];
        while ($cursor->lessThanOrEqualTo($currentCycle) && count($due) < $cap) {
            $due[] = $cursor->copy();
            $cursor = $cursor->copy()->addMonthsNoOverflow($length);
        }

        return $due;
    }

    /**
     * How many cycles are still owed after a capped run has taken $cap of them.
     * Zero means the player is fully caught up.
     */
    public static function remainingAfter(
        Carbon|string $nextBillingDate,
        Carbon|string|null $today = null,
        ?string $cycle = null,
        ?int $programDurationWeeks = null,
        int $cap = 3
    ): int {
        $length = self::lengthInMonths($cycle, $programDurationWeeks);
        $cursor = self::startOf($nextBillingDate);
        $currentCycle = self::startOf($today);

        $total = 0;
        while ($cursor->lessThanOrEqualTo($currentCycle)) {
            $total++;
            $cursor = $cursor->copy()->addMonthsNoOverflow($length);
        }

        return max(0, $total - $cap);
    }

    private static function carbon(Carbon|string|null $date): Carbon
    {
        if ($date instanceof Carbon) {
            return $date->copy();
        }

        return $date === null ? Carbon::now() : Carbon::parse($date);
    }
}
