<?php

namespace App\Domain\Financial\Support;

/**
 * Reading a hand-typed invoice line: is it a programme subscription, and which
 * month or months does it actually pay for?
 *
 * Both questions used to be answered by proxies that are wrong on real data:
 *
 *   "is it a subscription" was `itemable_type IS NULL`, which is true of every
 *   line a receptionist typed by hand — so a kit ("الزي"), a bag ("شنطة لبس")
 *   and a federation registration fee ("قسط القيد") all counted as subscription
 *   money and inflated what a player appeared to have paid for training.
 *
 *   "which month" was the invoice's issue_date, which is only the covered month
 *   when the invoice was raised inside it. A back-dated month typed up in
 *   September, or one invoice covering July and August together, both land on
 *   the wrong month — and a roster that says "paid" for a month nobody paid for
 *   is worse than one that says nothing.
 *
 * Everything here is a pure function over text: no database, no state. The
 * matching rules deliberately work on whole normalised WORDS, never substrings
 * — "تجهيزي" (preparatory) contains the letters of "زي" (kit), and a substring
 * match would reclassify a real subscription as merchandise.
 */
final class SubscriptionLine
{
    /**
     * Month names as they survive normalise(): hamza forms folded onto ا,
     * ة onto ه, ى onto ي. Egyptian/Gulf calendar names plus the English ones,
     * because both get typed into the same box.
     */
    private const MONTH_WORDS = [
        'يناير' => 1,  'january' => 1,  'jan' => 1,
        'فبراير' => 2, 'february' => 2, 'feb' => 2,
        'مارس' => 3,   'march' => 3,    'mar' => 3,
        'ابريل' => 4,  'april' => 4,    'apr' => 4,
        'مايو' => 5,   'may' => 5,
        'يونيو' => 6,  'يونيه' => 6,    'june' => 6,   'jun' => 6,
        'يوليو' => 7,  'يوليه' => 7,    'july' => 7,   'jul' => 7,
        'اغسطس' => 8,  'august' => 8,   'aug' => 8,
        'سبتمبر' => 9, 'september' => 9, 'sep' => 9,
        'اكتوبر' => 10, 'october' => 10, 'oct' => 10,
        'نوفمبر' => 11, 'november' => 11, 'nov' => 11,
        'ديسمبر' => 12, 'december' => 12, 'dec' => 12,
    ];

    /**
     * Words that mean this line is NOT training subscription, matched as whole
     * words after the definite article is stripped.
     *
     * قيد  — federation registration / enrolment fee, always a separate charge
     * زي / شنطه / ملابس — kit, bag, clothing: merchandise, not training
     * قسط / اقساط / دفعه — an instalment of something this line does not name.
     *   Training is billed by the month here, never in instalments, so a line
     *   that says only "القسط الاول" is paying off a registration card or a kit.
     *   Counting it as the month's subscription is what made a player who paid
     *   2,500 toward his card read as having paid 2,500 for July's training.
     *
     * A programme whose own name contains one of these still wins (see
     * isSubscription), so naming a programme "قيد الأولمبي" cannot erase its
     * subscriptions from the roster, and "قسط اشتراك: فريق 2011" stays
     * subscription because it names the programme.
     */
    private const NOT_SUBSCRIPTION_WORDS = ['قيد', 'زي', 'شنطه', 'ملابس', 'قسط', 'اقساط', 'دفعه'];

    /** Words that turn two named months into every month between them. */
    private const RANGE_WORDS = ['الي', 'حتي', 'لغايه', 'وحتي', '-'];

    /**
     * Fold the spelling variants Arabic text arrives in, so comparisons can be
     * exact rather than fuzzy: strip tashkeel and tatweel, unify hamza/alef,
     * ة→ه, ى→ي, Arabic-Indic digits→ASCII, punctuation→space.
     */
    public static function normalise(?string $text): string
    {
        if ($text === null || $text === '') {
            return '';
        }

        // Tashkeel (harakat) and tatweel carry no meaning for matching.
        $text = preg_replace('/[\x{064B}-\x{0652}\x{0640}\x{0670}]/u', '', $text) ?? '';

        $text = strtr($text, [
            'أ' => 'ا', 'إ' => 'ا', 'آ' => 'ا', 'ٱ' => 'ا',
            'ى' => 'ي', 'ئ' => 'ي', 'ة' => 'ه', 'ؤ' => 'و',
            '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
            '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
            '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
            '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
        ]);

        // A hyphen survives as its own token: it is one of the range markers.
        $text = preg_replace('/[^\p{L}\p{N}\-]+/u', ' ', $text) ?? '';
        $text = preg_replace('/\s+/u', ' ', $text) ?? '';

        return mb_strtolower(trim($text));
    }

    /**
     * Normalised words with the definite article removed, so "القيد" and "قيد"
     * are one word and "تجهيزي" stays one word rather than containing "زي".
     *
     * Public because BundledProductLine reads the same lines and must split
     * them into words identically — two splitters would eventually disagree
     * about whether a line is subscription money or product money, and the same
     * piastres would show up in both columns of the roster.
     *
     * @return array<int, string>
     */
    public static function words(string $normalised): array
    {
        $out = [];
        foreach (explode(' ', $normalised) as $word) {
            if ($word === '') {
                continue;
            }
            if (mb_strlen($word) > 3 && str_starts_with($word, 'ال')) {
                $word = mb_substr($word, 2);
            }
            $out[] = $word;
        }

        return $out;
    }

    /**
     * The proration suffix the pricing engine appends ("(متناسب: 13 من 30 يوم)")
     * describes how much of a month was charged, not what was sold — it must not
     * take part in matching the line against a programme name.
     */
    public static function withoutProrationSuffix(string $description): string
    {
        return trim(preg_replace('/\s*\(\s*متناسب.*$/u', '', $description) ?? $description);
    }

    /**
     * Does this hand-typed line pay for programme training?
     *
     * Ordered so the academy's own vocabulary beats the generic keywords:
     * a line naming a product is merchandise, a line naming a programme is
     * subscription, and only then do the "registration fee / kit" words apply.
     * Anything still unrecognised counts as subscription, which is the
     * behaviour this replaced — an unknown line keeps being read the way it
     * always was rather than silently vanishing from a player's total.
     *
     * @param array<int, string> $programNames name_ar of every programme in the academy
     * @param array<int, string> $productNames name_ar of every product in the academy
     */
    public static function isSubscription(?string $description, array $programNames = [], array $productNames = []): bool
    {
        $line = self::normalise(self::withoutProrationSuffix((string) $description));

        if ($line === '') {
            return true;
        }

        foreach ($productNames as $product) {
            if (($p = self::normalise($product)) !== '' && $line === $p) {
                return false;
            }
        }

        foreach ($programNames as $program) {
            $p = self::normalise($program);
            if ($p === '') {
                continue;
            }
            // Containment needs a name long enough to be distinctive; a
            // two-letter programme ("GK") would otherwise match half the shop.
            if ($line === $p || (mb_strlen($p) >= 3 && str_contains($line, $p))) {
                return true;
            }
        }

        foreach (self::words($line) as $word) {
            if (in_array($word, self::NOT_SUBSCRIPTION_WORDS, true)) {
                return false;
            }
        }

        return true;
    }

    /**
     * The months this line pays for, as 'YYYY-MM', most trustworthy source first:
     *
     *   1. the invoice's own metadata — the retroactive wizard records the month
     *      it is catching up on, and nothing beats a value written on purpose;
     *   2. months named in the line itself ("اشتراك يوليو 2026", "اشتراك شهر 8");
     *   3. months named in the invoice notes;
     *   4. the issue date, which is right whenever the invoice was raised in the
     *      month it covers — the common case, and the only one available before
     *      any of the above exist.
     *
     * Always returns at least one month, in chronological order.
     *
     * @param array<string, mixed> $invoiceMetadata
     * @return array<int, string>
     */
    public static function monthsCovered(
        ?string $description,
        ?string $notes,
        array $invoiceMetadata,
        string $issueDate
    ): array {
        return self::explicitMonths($description, $notes, $invoiceMetadata, $issueDate)
            ?: [substr($issueDate, 0, 7)];
    }

    /**
     * The same reading, but empty when the invoice says nothing about which
     * month it is for — steps 1 to 3 above, without the issue-date fallback.
     *
     * Callers need that distinction: with an explicit month a line can be
     * placed in a cycle it was not issued in, while a silent line must keep
     * being judged by its issue date, which is the only evidence there is.
     *
     * @param array<string, mixed> $invoiceMetadata
     * @return array<int, string>
     */
    public static function explicitMonths(
        ?string $description,
        ?string $notes,
        array $invoiceMetadata,
        string $issueDate
    ): array {
        if ($fromMetadata = self::monthsFromMetadata($invoiceMetadata)) {
            return $fromMetadata;
        }

        foreach ([$description, $notes] as $text) {
            if ($months = self::monthsFromText((string) $text, $issueDate)) {
                return $months;
            }
        }

        return [];
    }

    /**
     * @param array<string, mixed> $metadata
     * @return array<int, string>
     */
    private static function monthsFromMetadata(array $metadata): array
    {
        if (is_string($metadata['month'] ?? null) && preg_match('/^(\d{4})-(\d{2})$/', $metadata['month'], $m)) {
            return [$m[0]];
        }

        $start = $metadata['period_start'] ?? null;
        $end = $metadata['period_end'] ?? null;

        if (is_string($start) && preg_match('/^(\d{4})-(\d{2})/', $start, $s)) {
            $from = "{$s[1]}-{$s[2]}";
            $to = (is_string($end) && preg_match('/^(\d{4})-(\d{2})/', $end, $e)) ? "{$e[1]}-{$e[2]}" : $from;

            return self::expand($from, $to);
        }

        return [];
    }

    /**
     * @return array<int, string>
     */
    private static function monthsFromText(string $text, string $issueDate): array
    {
        $normalised = self::normalise($text);
        if ($normalised === '') {
            return [];
        }

        $words = explode(' ', $normalised);

        // An explicit YYYY-MM is unambiguous and outranks everything else.
        // Matched per word rather than across the string, so a programme named
        // "اكاديمية 2012-2013" cannot be read as a date.
        $iso = [];
        foreach ($words as $word) {
            if (preg_match('/^((?:19|20|21)\d{2})-(0[1-9]|1[0-2])$/', $word, $m)) {
                $iso[] = "{$m[1]}-{$m[2]}";
            }
        }
        if ($iso !== []) {
            return self::rangeOrList($iso, $normalised);
        }

        $issueYear = (int) substr($issueDate, 0, 4);

        // A year is only believable if it is near the invoice: a programme called
        // "فريق 2011/2012" must not date a 2026 subscription to 2011.
        $year = null;
        foreach ($words as $word) {
            if (preg_match('/^(19|20|21)\d{2}$/', $word) && abs((int) $word - $issueYear) <= 1) {
                $year = (int) $word;
                break;
            }
        }

        $found = [];
        foreach ($words as $i => $word) {
            $bare = (mb_strlen($word) > 3 && str_starts_with($word, 'ال')) ? mb_substr($word, 2) : $word;

            $number = self::MONTH_WORDS[$bare] ?? null;

            // "يوليو وأغسطس" — the conjunction is written onto the next word.
            // Only ever consulted when the whole remainder is a month name, so
            // it cannot turn an ordinary word into a date.
            if ($number === null && str_starts_with($bare, 'و')) {
                $number = self::MONTH_WORDS[mb_substr($bare, 1)] ?? null;
            }

            // "شهر 8" — the word "month" followed by its number.
            if ($number === null && $bare === 'شهر' && isset($words[$i + 1]) && preg_match('/^(0?[1-9]|1[0-2])$/', $words[$i + 1])) {
                $number = (int) $words[$i + 1];
            }

            if ($number !== null) {
                $found[] = $number;
            }
        }

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

        $months = [];
        foreach ($found as $number) {
            $months[] = sprintf('%04d-%02d', self::yearFor($number, $year, $issueDate), $number);
        }

        return self::rangeOrList($months, $normalised);
    }

    /**
     * Which year a bare month name belongs to. An explicit nearby year wins;
     * otherwise it is the year that puts the month closest to the issue date,
     * so "اشتراك ديسمبر" typed on 2 January means the December just gone.
     */
    private static function yearFor(int $month, ?int $explicitYear, string $issueDate): int
    {
        if ($explicitYear !== null) {
            return $explicitYear;
        }

        $issueYear = (int) substr($issueDate, 0, 4);
        $issueMonth = (int) substr($issueDate, 5, 2);

        $distance = $month - $issueMonth;

        if ($distance > 6) {
            return $issueYear - 1;
        }

        if ($distance < -6) {
            return $issueYear + 1;
        }

        return $issueYear;
    }

    /**
     * Two months with a range word between them mean everything in between —
     * "اشتراك يوليو إلى سبتمبر" is three months, not two.
     *
     * @param array<int, string> $months
     * @return array<int, string>
     */
    private static function rangeOrList(array $months, string $normalised): array
    {
        $months = array_values(array_unique($months));
        sort($months);

        if (count($months) === 2) {
            foreach (self::RANGE_WORDS as $word) {
                if (in_array($word, explode(' ', $normalised), true)) {
                    return self::expand($months[0], $months[1]);
                }
            }
        }

        return $months;
    }

    /**
     * Every month from $from to $to inclusive. Capped at two years so a typo in
     * a year cannot produce an unbounded list.
     *
     * @return array<int, string>
     */
    private static function expand(string $from, string $to): array
    {
        if ($to < $from) {
            [$from, $to] = [$to, $from];
        }

        $months = [];
        $year = (int) substr($from, 0, 4);
        $month = (int) substr($from, 5, 2);

        for ($i = 0; $i < 24; $i++) {
            $current = sprintf('%04d-%02d', $year, $month);
            $months[] = $current;

            if ($current >= $to) {
                break;
            }

            if (++$month > 12) {
                $month = 1;
                $year++;
            }
        }

        return $months;
    }
}
