<?php

namespace App\Domain\Shared\Support;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;

/**
 * Searching for a person by their Arabic name, the way the name was actually
 * typed.
 *
 * `where('name_ar', 'like', "%{$term}%")` fails on this data constantly, for two
 * reasons that have nothing to do with the searcher being careless:
 *
 *   Spelling. "عبدالله أحمد" and "عبدالله احمد" are the same child; so are
 *   "يحيى"/"يحيي" and "حمزة"/"حمزه". Whoever typed the row and whoever is
 *   searching for it rarely agree about hamza, ة/ه or ى/ي, and neither of them
 *   is wrong.
 *
 *   Word order and gaps. A name on file is four or five words —
 *   "عبدالله أحمد صلاح سيد" — and the person searching types the two they
 *   remember, "عبدالله صلاح". A single LIKE over the whole phrase matches
 *   nothing, because those two words are not adjacent.
 *
 * So both sides are folded to one spelling and the term is split into words,
 * each of which must appear somewhere in the name. The folding happens in SQL
 * too (Postgres `translate`, character for character) so the database does the
 * matching rather than the application pulling every participant into memory.
 */
final class ArabicSearch
{
    /**
     * Letter variants that mean the same name. Both strings must have the same
     * number of characters: translate() maps them one to one.
     */
    private const FROM = 'أإآٱىئةؤ';

    private const TO = 'ااااييهو';

    /** Fold a search term or a stored name to the one spelling both are compared in. */
    public static function normalise(?string $text): string
    {
        if ($text === null || $text === '') {
            return '';
        }

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

        $map = [];
        $from = preg_split('//u', self::FROM, -1, PREG_SPLIT_NO_EMPTY);
        $to = preg_split('//u', self::TO, -1, PREG_SPLIT_NO_EMPTY);
        foreach ($from as $i => $char) {
            $map[$char] = $to[$i];
        }

        // Arabic-Indic digits, so a phone typed as ٠١٠٠ finds 0100.
        $map += [
            '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
            '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
            '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
            '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
        ];

        $text = strtr($text, $map);
        $text = preg_replace('/\s+/u', ' ', $text) ?? '';

        return mb_strtolower(trim($text));
    }

    /** The words a term is made of, each of which must appear in the name. */
    public static function words(?string $term): array
    {
        $normalised = self::normalise($term);

        if ($normalised === '') {
            return [];
        }

        return array_values(array_filter(explode(' ', $normalised), fn ($w) => $w !== ''));
    }

    /**
     * Apply the search to a query over `people`, folding the stored names the
     * same way.
     *
     * Only Postgres has translate(); on any other driver (the SQLite test
     * suite) the folding is skipped and a plain word-wise LIKE is used, which
     * still fixes the word-order half of the problem.
     *
     * @param array<int, string> $columns qualified name columns, e.g. people.name_ar
     */
    public static function apply(Builder $query, string $term, array $columns, ?string $phoneColumn = null): Builder
    {
        $words = self::words($term);

        if ($words === []) {
            return $query;
        }

        $folds = DB::connection()->getDriverName() === 'pgsql';

        foreach ($words as $word) {
            $query->where(function (Builder $inner) use ($word, $columns, $phoneColumn, $folds) {
                foreach ($columns as $column) {
                    $expression = $folds
                        ? DB::raw("lower(translate({$column}, '" . self::FROM . "', '" . self::TO . "'))")
                        : DB::raw("lower({$column})");

                    $inner->orWhere($expression, 'like', '%' . $word . '%');
                }

                if ($phoneColumn) {
                    $inner->orWhere($phoneColumn, 'like', '%' . $word . '%');
                }
            });
        }

        return $query;
    }

    /** Does this stored name match the term? Same rule, for in-memory lists. */
    public static function matches(?string $haystack, string $term): bool
    {
        $name = self::normalise($haystack);

        foreach (self::words($term) as $word) {
            if (! str_contains($name, $word)) {
                return false;
            }
        }

        return true;
    }
}
