<?php

namespace App\Policy\Service;

/**
 * فهرست بستهٔ عملگرها — یازده عملگر بند ۸ مستند.
 *
 * «بسته» یعنی عملگری که اینجا نیست، هنگام **ذخیره** رد می‌شود نه در زمان اجرا. قانونی که
 * موقع رزرو بیمار بترکد، بدترین جای ممکن برای شکستن است.
 *
 * مقایسه عمداً ساده است و هیچ کد دلخواهی اجرا نمی‌کند: هر عملگر یک تابع خالص روی دو
 * مقدار است، پس همان قانون را می‌شود روزی به SQL ترجمه کرد.
 */
final class OperatorRegistry
{
    public const OP_EQUALS        = 'equals';
    public const OP_NOT_EQUALS    = 'not_equals';
    public const OP_GREATER_THAN  = 'greater_than';
    public const OP_GREATER_EQUAL = 'greater_or_equal';
    public const OP_LESS_THAN     = 'less_than';
    public const OP_LESS_EQUAL    = 'less_or_equal';
    public const OP_IN            = 'in';
    public const OP_NOT_IN        = 'not_in';
    public const OP_BETWEEN       = 'between';
    public const OP_CONTAINS      = 'contains';

    /**
     * «چند روز از این زمان گذشته» — عملگر ویژهٔ مستند.
     *
     * روی فیلدی کار می‌کند که مقدارش یک timestamp است و مقایسه‌اش با یک عدد روز انجام
     * می‌شود: `last_visit_at days_since 30` یعنی «بیش از سی روز از آخرین ویزیت گذشته».
     * بدون این، همان شرط باید در هر نقطهٔ مصرف دستی حساب می‌شد.
     */
    public const OP_DAYS_SINCE = 'days_since';

    public const ALL = [
        self::OP_EQUALS,
        self::OP_NOT_EQUALS,
        self::OP_GREATER_THAN,
        self::OP_GREATER_EQUAL,
        self::OP_LESS_THAN,
        self::OP_LESS_EQUAL,
        self::OP_IN,
        self::OP_NOT_IN,
        self::OP_BETWEEN,
        self::OP_CONTAINS,
        self::OP_DAYS_SINCE,
    ];

    /** برچسب فارسیِ هر عملگر — همان چیزی که در فرم ساخت قانون دیده می‌شود. */
    private const LABELS = [
        self::OP_EQUALS        => 'برابر است با',
        self::OP_NOT_EQUALS    => 'برابر نیست با',
        self::OP_GREATER_THAN  => 'بیشتر از',
        self::OP_GREATER_EQUAL => 'بیشتر یا مساوی',
        self::OP_LESS_THAN     => 'کمتر از',
        self::OP_LESS_EQUAL    => 'کمتر یا مساوی',
        self::OP_IN            => 'یکی از',
        self::OP_NOT_IN        => 'هیچ‌کدام از',
        self::OP_BETWEEN       => 'بین',
        self::OP_CONTAINS      => 'شامل',
        self::OP_DAYS_SINCE    => 'روز گذشته از',
    ];

    /** عملگرهای معنادار per نوع فیلد — فرم فقط همین‌ها را نشان می‌دهد. */
    private const BY_TYPE = [
        'int'       => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_GREATER_THAN, self::OP_GREATER_EQUAL, self::OP_LESS_THAN, self::OP_LESS_EQUAL, self::OP_BETWEEN, self::OP_IN, self::OP_NOT_IN],
        'uuid'      => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN, self::OP_NOT_IN],
        'enum'      => [self::OP_EQUALS, self::OP_NOT_EQUALS, self::OP_IN, self::OP_NOT_IN],
        'bool'      => [self::OP_EQUALS],
        'list'      => [self::OP_CONTAINS],
        'timestamp' => [self::OP_DAYS_SINCE, self::OP_GREATER_THAN, self::OP_LESS_THAN],
    ];

    public function has(string $operator): bool
    {
        return in_array($operator, self::ALL, true);
    }

    /** @return list<string> */
    public function forType(string $type): array
    {
        return self::BY_TYPE[$type] ?? [self::OP_EQUALS, self::OP_NOT_EQUALS];
    }

    /** @return list<array{value: string, label: string}> */
    public function describe(): array
    {
        return array_map(
            static fn (string $op): array => ['value' => $op, 'label' => self::LABELS[$op]],
            self::ALL,
        );
    }

    public function label(string $operator): string
    {
        return self::LABELS[$operator] ?? $operator;
    }

    /**
     * ارزیابی یک عملگر. `$now` تزریق می‌شود تا `days_since` در تست قطعی باشد.
     */
    public function evaluate(string $operator, mixed $actual, mixed $expected, ?int $now = null): bool
    {
        return match ($operator) {
            self::OP_EQUALS        => $this->looselyEqual($actual, $expected),
            self::OP_NOT_EQUALS    => !$this->looselyEqual($actual, $expected),
            self::OP_GREATER_THAN  => is_numeric($actual) && is_numeric($expected) && $actual > $expected,
            self::OP_GREATER_EQUAL => is_numeric($actual) && is_numeric($expected) && $actual >= $expected,
            self::OP_LESS_THAN     => is_numeric($actual) && is_numeric($expected) && $actual < $expected,
            self::OP_LESS_EQUAL    => is_numeric($actual) && is_numeric($expected) && $actual <= $expected,
            self::OP_IN            => is_array($expected) && $this->inList($actual, $expected),
            self::OP_NOT_IN        => is_array($expected) && !$this->inList($actual, $expected),
            self::OP_BETWEEN       => $this->between($actual, $expected),
            self::OP_CONTAINS      => is_array($actual) && $this->inList($expected, $actual),
            self::OP_DAYS_SINCE    => $this->daysSince($actual, $expected, $now),
            default                => false,
        };
    }

    /**
     * بازهٔ بسته: `[min, max]`. هر دو سر شمرده می‌شوند، چون «بین ۱۸ تا ۶۵ سال» در زبان
     * فارسی هر دو سر را شامل می‌شود و کاربر همان را می‌نویسد.
     */
    private function between(mixed $actual, mixed $expected): bool
    {
        if (!is_array($expected) || count($expected) !== 2 || !is_numeric($actual)) {
            return false;
        }

        [$min, $max] = array_values($expected);

        return is_numeric($min) && is_numeric($max) && $actual >= $min && $actual <= $max;
    }

    /** «بیش از N روز از این زمان گذشته». مقدار غایب یعنی «هرگز» و شرط را رد می‌کند. */
    private function daysSince(mixed $actual, mixed $expected, ?int $now): bool
    {
        if (!is_numeric($actual) || $actual <= 0 || !is_numeric($expected)) {
            return false;
        }

        $days = ((($now ?? time()) - (int) $actual) / 86400);

        return $days >= (float) $expected;
    }

    /** @param array<int, mixed> $list */
    private function inList(mixed $needle, array $list): bool
    {
        foreach ($list as $candidate) {
            if ($this->looselyEqual($needle, $candidate)) {
                return true;
            }
        }

        return false;
    }

    /**
     * مقایسهٔ ملایم فقط بین عدد و رشتهٔ عددی — `"18" == 18` درست است ولی
     * `"18 سال" == 18` نه. مقایسهٔ `==` خام PHP دومی را هم درست می‌گفت.
     */
    private function looselyEqual(mixed $a, mixed $b): bool
    {
        if (is_numeric($a) && is_numeric($b)) {
            return (float) $a === (float) $b;
        }

        if (is_bool($a) || is_bool($b)) {
            return (bool) $a === (bool) $b;
        }

        return $a === $b;
    }
}
