<?php

namespace App\Domain\Financial\Support;

/**
 * Reading a hand-typed invoice line: does it pay for a product the programme
 * requires — the federation registration card, the kit — rather than for
 * training?
 *
 * The roster's product columns used to ask the database a much narrower
 * question: is there an invoice line whose itemable morph points at this
 * product? On a real academy's books that misses most of the money. A
 * receptionist taking the first instalment of the registration fee types
 * "القسط الاول من القيد" into a free-text line, or "قسط قيد ياسين" with the
 * child's name in it; the money is collected and the card is on its way, but
 * the roster shows the player as never having bought it. On OC-Sport that was
 * fourteen lines across nine players, every one of whom had paid something.
 *
 * Matching therefore works on the product's own name, over whole normalised
 * WORDS (SubscriptionLine::normalise), never over substrings — the same rule
 * and the same reason as SubscriptionLine.
 *
 * The words that identify a product are the ones the academy does not also use
 * for everything else. "قيد اشتراك فريق اتحاد الكرة" identifies itself by قيد
 * and اتحاد, because اشتراك and فريق are how half the subscription lines in the
 * same ledger begin. Any word appearing in a programme's name is dropped for
 * exactly that reason: it cannot tell a product line from a subscription line.
 */
final class BundledProductLine
{
    /**
     * Words that never identify a product on their own, however they are used
     * in its name: the vocabulary of billing itself, plus the generic sport
     * words every academy's catalogue shares.
     */
    private const GENERIC_WORDS = [
        'اشتراك', 'اشتراكات', 'تجديد', 'رسوم', 'رسم', 'مصاريف',
        'فريق', 'فرق', 'نادي', 'اكاديميه', 'كره', 'مباراه', 'تدريب',
        'شهر', 'شهري', 'سنوي', 'سنه', 'موسم',
        'قسط', 'اقساط', 'دفعه', 'دفعات', 'مبلغ', 'باقي', 'متبقي', 'خالص',
        'بند', 'لاعب', 'عضو',
    ];

    /** A line paying part of something agreed, without saying part of what. */
    private const INSTALMENT_WORDS = ['قسط', 'اقساط', 'قسطين', 'دفعه', 'دفعات', 'مقدم'];

    /**
     * The words that, on their own, mean a line is about this product.
     *
     * @param array<int, string> $programNames name_ar of every programme in the academy
     * @return array<int, string>
     */
    public static function identifyingWords(string $productName, array $programNames = []): array
    {
        $programWords = [];
        foreach ($programNames as $program) {
            foreach (SubscriptionLine::words(SubscriptionLine::normalise($program)) as $word) {
                $programWords[$word] = true;
            }
        }

        $out = [];
        foreach (SubscriptionLine::words(SubscriptionLine::normalise($productName)) as $word) {
            // Two letters cannot identify anything: "زي" (kit) lives inside
            // "تجهيزي" (preparatory), and numbers are how programmes are named.
            if (mb_strlen($word) < 3 || is_numeric($word)) {
                continue;
            }
            if (in_array($word, self::GENERIC_WORDS, true) || isset($programWords[$word])) {
                continue;
            }
            $out[$word] = true;
        }

        return array_keys($out);
    }

    /**
     * Does this hand-typed line pay for the product?
     *
     * @param array<int, string> $identifyingWords from identifyingWords()
     */
    public static function matches(?string $description, string $productName, array $identifyingWords): bool
    {
        $line = SubscriptionLine::normalise(SubscriptionLine::withoutProrationSuffix((string) $description));
        if ($line === '') {
            return false;
        }

        // Typed out verbatim, it is the product whatever its name is made of.
        // Containment is deliberately not a rule: a name containing an
        // identifying word is already caught below, and a name made entirely of
        // generic words ("فريق 2015", a product named after a programme) would
        // otherwise swallow every subscription line that mentions it.
        $name = SubscriptionLine::normalise($productName);
        if ($name !== '' && $line === $name) {
            return true;
        }

        foreach (SubscriptionLine::words($line) as $word) {
            if (in_array($word, $identifyingWords, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * A line that pays an instalment of something it never names — "القسط
     * الاول", "الشنطة و القسط الاول".
     *
     * Alone this says nothing about which bill is being paid; the caller has to
     * supply that context (see ParticipantBillingService: the invoice must
     * name no programme, and the programme must require exactly one product).
     */
    public static function isBareInstalment(?string $description): bool
    {
        foreach (SubscriptionLine::words(SubscriptionLine::normalise((string) $description)) as $word) {
            if (in_array($word, self::INSTALMENT_WORDS, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Does the line name one of the academy's programmes? Same containment rule
     * SubscriptionLine uses, so the two agree on what a subscription line is.
     *
     * @param array<int, string> $programNames
     */
    public static function namesProgram(?string $description, array $programNames): bool
    {
        $line = SubscriptionLine::normalise(SubscriptionLine::withoutProrationSuffix((string) $description));
        if ($line === '') {
            return false;
        }

        foreach ($programNames as $program) {
            $name = SubscriptionLine::normalise($program);
            if ($name === '') {
                continue;
            }
            if ($line === $name || (mb_strlen($name) >= 3 && str_contains($line, $name))) {
                return true;
            }
        }

        return false;
    }
}
