<?php

declare(strict_types=1);

namespace CliqThemes\UI\Data;

use CliqThemes\ORM\Model;
use CliqThemes\UI\Contracts\DataProvider;

/**
 * Bridges CliqThemes ORM models to the UI data provider contract.
 */
class OrmTableProvider implements DataProvider
{
    /**
     * @var class-string<Model>
     */
    private string $modelClass;

    /**
     * @var array<string,array<string,mixed>>
     */
    private array $columns;

    /**
     * @var array<string,array<string,mixed>>
     */
    private array $filters;

    /**
     * Host-supplied list processors. Each is `fn(array $records, array $args): array`
     * and runs after records are normalised but BEFORE filtering/sorting/pagination,
     * so a processor may add, remove or rewrite rows and the totals stay correct.
     *
     * This is the framework's generic extension seam (Open/Closed + DIP): the
     * provider knows nothing about what a processor does — consumers inject
     * cross-cutting concerns (multilingual scoping/overlay, tenancy, computed
     * fields) without modifying the framework.
     *
     * @var array<int, callable>
     */
    private array $listProcessors = [];

    public function __construct(string $modelClass, array $columns = [], array $filters = [])
    {
        if (! is_subclass_of($modelClass, Model::class)) {
            throw new \InvalidArgumentException(esc_html(sprintf(
                'Model class "%s" must extend %s.',
                $modelClass,
                Model::class
            )));
        }

        $this->modelClass = $modelClass;
        $this->columns = $columns;
        $this->filters = $filters;
    }

    /**
     * Register a list processor (see {@see $listProcessors}). Fluent so providers
     * can be configured at registration: `(new OrmTableProvider(...))->addListProcessor($fn)`.
     */
    public function addListProcessor(callable $processor): static
    {
        $this->listProcessors[] = $processor;

        return $this;
    }

    /**
     * Retrieves ORM models, normalises them to arrays and applies pagination, sorting and filters.
     *
     * @param array<string,mixed> $args Supported keys: per_page, page, order_by, order, filters.
     */
    public function list(array $args = []): iterable
    {
        $records = array_map(
            fn (Model $model): array => $this->normaliseModel($model),
            $this->resolveAll()
        );

        // Host processors run before filtering/sorting/pagination so row counts
        // and pages reflect their result.
        foreach ($this->listProcessors as $processor) {
            $records = array_values($processor($records, $args));
        }

        $records = $this->applyFilters($records, $args['filters'] ?? []);

        if (isset($args['order_by'])) {
            $orderBy = (string) $args['order_by'];
            $order = strtolower((string) ($args['order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';

            usort($records, static function (array $a, array $b) use ($orderBy, $order): int {
                $valueA = $a[$orderBy] ?? null;
                $valueB = $b[$orderBy] ?? null;

                if ($valueA === $valueB) {
                    return 0;
                }

                if ($order === 'asc') {
                    return ($valueA <=> $valueB);
                }

                return ($valueB <=> $valueA);
            });
        }

        $total = count($records);
        $perPage = max(1, (int) ($args['per_page'] ?? $total ?: 1));
        $page = max(1, (int) ($args['page'] ?? 1));
        $offset = ($page - 1) * $perPage;

        $slice = array_slice($records, $offset, $perPage);

        return new PaginatedResult($slice, $total, $perPage, $page);
    }

    /**
     * Fetches a single model by identifier and returns the serialised representation.
     *
     * @param mixed                     $identifier
     * @param array<string,mixed>       $context
     *
     * @return array<string,mixed>
     */
    public function retrieve($identifier, array $context = []): array
    {
        $model = $this->modelClass::find($identifier);

        if (! $model instanceof Model) {
            return [];
        }

        return $this->normaliseModel($model);
    }

    /**
     * Persists a new model instance and returns the stored payload.
     *
     * @param array<string,mixed>       $payload
     * @param array<string,mixed>       $context
     *
     * @return array<string,mixed>
     */
    public function create(array $payload, array $context = []): array
    {
        /** @var Model $model */
        $model = $this->modelClass::create($payload);

        return $this->normaliseModel($model);
    }

    /**
     * Updates an existing model and returns the serialised record.
     *
     * @param mixed                     $identifier
     * @param array<string,mixed>       $payload
     * @param array<string,mixed>       $context
     *
     * @return array<string,mixed>
     */
    public function update($identifier, array $payload, array $context = []): array
    {
        $model = $this->modelClass::find($identifier);

        if (! $model instanceof Model) {
            throw new \InvalidArgumentException(esc_html(sprintf(
                'Unable to locate model "%s" with identifier "%s".',
                $this->modelClass,
                (string) $identifier
            )));
        }

        $model->fill($payload);
        $model->save();

        return $this->normaliseModel($model);
    }

    /**
     * Deletes the model identified by the provided key.
     *
     * @param mixed               $identifier
     * @param array<string,mixed>  $context
     *
     * @return bool
     */
    public function delete($identifier, array $context = []): bool
    {
        $model = $this->modelClass::find($identifier);

        if (! $model instanceof Model) {
            return false;
        }

        return $model->delete();
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    public function columns(): array
    {
        return $this->columns;
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    public function filters(): array
    {
        return $this->filters;
    }

    /**
     * @return array<int,Model>
     */
    private function resolveAll(): array
    {
        $results = $this->modelClass::all();

        if (! is_array($results)) {
            return [];
        }

        return array_filter($results, static fn ($model): bool => $model instanceof Model);
    }

    private function normaliseModel(Model $model): array
    {
        return $model->jsonSerialize();
    }

    /**
     * @param array<int,array<string,mixed>> $records
     * @param array<string,mixed>            $filters
     *
     * @return array<int,array<string,mixed>>
     */
    private function applyFilters(array $records, array $filters): array
    {
        if ($filters === []) {
            return $records;
        }

        return array_values(array_filter($records, function (array $record) use ($filters): bool {
            foreach ($filters as $key => $value) {
                if ($value === null || $value === '' || $value === []) {
                    continue;
                }

                $definition = $this->filters[(string) $key] ?? [];
                $operator = strtolower((string) ($definition['operator'] ?? 'equals'));
                $fields = $definition['fields'] ?? null;
                $fieldKey = $definition['field'] ?? (string) $key;

                if (is_array($fields) && $fields !== []) {
                    $matched = false;

                    foreach ($fields as $candidate) {
                        $recordValue = $record[(string) $candidate] ?? null;

                        if ($this->matchesFilter($recordValue, $value, $operator)) {
                            $matched = true;
                            break;
                        }
                    }

                    if (! $matched) {
                        return false;
                    }

                    continue;
                }

                $recordValue = $record[(string) $fieldKey] ?? null;

                if (! $this->matchesFilter($recordValue, $value, $operator)) {
                    return false;
                }
            }

            return true;
        }));
    }

    /**
     * Evaluates whether a record value satisfies a configured filter rule.
     */
    private function matchesFilter($recordValue, $filterValue, string $operator): bool
    {
        switch ($operator) {
            case 'contains':
                if (is_array($recordValue)) {
                    $needles = is_array($filterValue) ? $filterValue : [$filterValue];

                    foreach ($needles as $needle) {
                        if (in_array($needle, $recordValue, true)) {
                            return true;
                        }
                    }

                    return false;
                }

                return stripos((string) $recordValue, (string) $filterValue) !== false;
            case 'not_equals':
                if (is_array($filterValue)) {
                    return ! in_array($recordValue, $filterValue, true);
                }

                return (string) $recordValue !== (string) $filterValue;
            case 'in':
                $needles = is_array($filterValue) ? $filterValue : [$filterValue];

                return in_array((string) $recordValue, array_map('strval', $needles), true);
            case 'not_in':
                $excluded = is_array($filterValue) ? $filterValue : [$filterValue];

                return ! in_array((string) $recordValue, array_map('strval', $excluded), true);
            case 'search':
                if (is_array($recordValue)) {
                    $haystack = implode(' ', array_map('strval', $recordValue));

                    return stripos($haystack, (string) $filterValue) !== false;
                }

                return stripos((string) $recordValue, (string) $filterValue) !== false;
            case 'equals':
            default:
                if (is_array($filterValue)) {
                    return in_array((string) $recordValue, array_map('strval', $filterValue), true);
                }

                return (string) $recordValue === (string) $filterValue;
        }
    }
}
