<?php

declare(strict_types=1);

namespace CliqThemes\UI\REST;

use CliqThemes\UI\Contracts\DataProvider;
use CliqThemes\UI\Data\PaginatedResult;
use Traversable;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

/**
 * REST controller that maps HTTP verbs to CRUD operations on a {@see DataProvider}.
 *
 * The controller normalises provider responses into a predictable array structure so the
 * JavaScript bundle can hydrate tables regardless of which adapter backed the provider.
 */
final class DataController
{
    private DataProvider $provider;

    private string $providerId;

    /**
     * @param string $providerId Identifier used to expose the provider over the API.
     */
    public function __construct(DataProvider $provider, string $providerId)
    {
        $this->provider = $provider;
        $this->providerId = $providerId;
    }

    /**
     * Handles `GET /{provider}` requests by returning a paginated dataset and metadata.
     *
     * @param WP_REST_Request $request Incoming REST request providing pagination, ordering and filter args.
     *
     * @return array<string,mixed>|WP_REST_Response Response payload normalised for the UI bundle.
     */
    public function listItems(WP_REST_Request $request): mixed
    {
        $args = [
            'per_page' => $request->get_param('per_page'),
            'page' => $request->get_param('page'),
            'order_by' => $request->get_param('order_by'),
            'order' => $request->get_param('order'),
            'filters' => $request->get_param('filters') ?? [],
            // Generic, namespaced bag of extra request parameters the provider
            // may interpret (e.g. a host's multilingual `lang`). The framework
            // forwards it verbatim without assigning it meaning.
            'context' => (array) ($request->get_param('context') ?? []),
        ];
        // Extensions can reshape the query (extra filters, forced scoping, …).
        $args = (array) $this->filter('cliqthemes_ui_rest_query_args', $args, ['request' => $request]);
        $result = $this->provider->list($args);

        if ($result instanceof PaginatedResult) {
            $records = $this->normaliseIterable($result->records());
            $pagination = $result->pagination();
        } else {
            $records = $this->normaliseIterable($result);
            $page = (int) ($request->get_param('page') ?? 1);
            $perPage = (int) ($request->get_param('per_page') ?? max(1, count($records)));
            $pagination = [
                'total' => count($records),
                'per_page' => max(1, $perPage),
                'page' => max(1, $page),
                'total_pages' => 1,
            ];
        }

        return $this->success((array) $this->filter('cliqthemes_ui_rest_list', [
            'provider' => $this->providerId,
            'records' => $records,
            'columns' => $this->provider->columns(),
            'filters' => $this->provider->filters(),
            'pagination' => $pagination,
        ], ['request' => $request]));
    }

    /**
     * Handles `GET /{provider}/{record_id}` requests for a single record.
     *
     * @return array<string,mixed>|WP_Error|WP_REST_Response
     */
    public function getItem(WP_REST_Request $request): mixed
    {
        $identifier = $this->identifierFromRequest($request);
        if ($identifier === null) {
            return $this->error('cliqthemes_ui_missing_identifier', __('Record identifier not provided.', 'cliq-store-locator'), 400);
        }

        $context = (array) ($request->get_param('context') ?? []);
        $record = $this->provider->retrieve($identifier, $context);
        if ($record === []) {
            return $this->error('cliqthemes_ui_not_found', __('Record not found.', 'cliq-store-locator'), 404);
        }

        $record = (array) $this->filter('cliqthemes_ui_rest_record', $record, ['id' => $identifier, 'op' => 'read', 'request' => $request]);

        return $this->success([
            'provider' => $this->providerId,
            'record' => $record,
        ]);
    }

    /**
     * Handles `POST /{provider}` requests by delegating to {@see DataProvider::create()}.
     *
     * @return array<string,mixed>|WP_Error|WP_REST_Response
     */
    public function createItem(WP_REST_Request $request): mixed
    {
        $payload = $this->readPayload($request);
        $payload = (array) $this->filter('cliqthemes_ui_rest_pre_save', $payload, ['op' => 'create', 'request' => $request]);
        $context = (array) ($request->get_param('context') ?? []);

        try {
            $record = $this->provider->create($payload, $context);
        } catch (\Throwable $exception) {
            return $this->error('cliqthemes_ui_create_failed', $exception->getMessage(), 500);
        }

        $this->action('cliqthemes_ui_rest_saved', ['op' => 'create', 'record' => $record, 'payload' => $payload]);
        $record = (array) $this->filter('cliqthemes_ui_rest_record', $record, ['op' => 'create', 'request' => $request]);

        return $this->success([
            'provider' => $this->providerId,
            'record' => $record,
        ], 201);
    }

    /**
     * Handles `PUT|PATCH /{provider}/{record_id}` requests by delegating to {@see DataProvider::update()}.
     *
     * @return array<string,mixed>|WP_Error|WP_REST_Response
     */
    public function updateItem(WP_REST_Request $request): mixed
    {
        $identifier = $this->identifierFromRequest($request);
        if ($identifier === null) {
            return $this->error('cliqthemes_ui_missing_identifier', __('Record identifier not provided.', 'cliq-store-locator'), 400);
        }

        $payload = $this->readPayload($request);
        $payload = (array) $this->filter('cliqthemes_ui_rest_pre_save', $payload, ['op' => 'update', 'id' => $identifier, 'request' => $request]);
        $context = (array) ($request->get_param('context') ?? []);

        try {
            $record = $this->provider->update($identifier, $payload, $context);
        } catch (\Throwable $exception) {
            return $this->error('cliqthemes_ui_update_failed', $exception->getMessage(), 500);
        }

        $this->action('cliqthemes_ui_rest_saved', ['op' => 'update', 'id' => $identifier, 'record' => $record, 'payload' => $payload]);
        $record = (array) $this->filter('cliqthemes_ui_rest_record', $record, ['op' => 'update', 'id' => $identifier, 'request' => $request]);

        return $this->success([
            'provider' => $this->providerId,
            'record' => $record,
        ]);
    }

    /**
     * Applies one provider-defined update to multiple records.
     *
     * Providers opt in by exposing bulkUpdate(). They own the action allow-list
     * and validation; this controller transports the authenticated request.
     */
    public function bulkUpdateItems(WP_REST_Request $request): mixed
    {
        if (! method_exists($this->provider, 'bulkUpdate')) {
            return $this->error('cliqthemes_ui_bulk_unsupported', __('Bulk updates are not available for this record type.', 'cliq-store-locator'), 405);
        }

        $payload = $this->readPayload($request);
        $ids = array_values(array_unique(array_filter(
            array_map('intval', (array) ($payload['ids'] ?? $request->get_param('ids') ?? [])),
            static fn (int $id): bool => $id > 0
        )));
        if ($ids === []) {
            return $this->error('cliqthemes_ui_bulk_missing_ids', __('Select at least one record.', 'cliq-store-locator'), 400);
        }

        try {
            $updated = $this->provider->bulkUpdate(
                $ids,
                (string) ($payload['action'] ?? ''),
                (string) ($payload['field'] ?? ''),
                $payload['value'] ?? null,
                (string) ($payload['mode'] ?? 'replace'),
                (array) ($request->get_param('context') ?? [])
            );
        } catch (\InvalidArgumentException $exception) {
            return $this->error('cliqthemes_ui_bulk_invalid', $exception->getMessage(), 400);
        } catch (\Throwable $exception) {
            return $this->error('cliqthemes_ui_bulk_failed', $exception->getMessage(), 500);
        }

        return $this->success([
            'provider' => $this->providerId,
            'updated' => max(0, (int) $updated),
        ]);
    }

    /**
     * Handles `DELETE /{provider}/{record_id}` requests by delegating to {@see DataProvider::delete()}.
     *
     * @return array<string,mixed>|WP_Error|WP_REST_Response
     */
    public function deleteItem(WP_REST_Request $request): mixed
    {
        $identifier = $this->identifierFromRequest($request);
        if ($identifier === null) {
            return $this->error('cliqthemes_ui_missing_identifier', __('Record identifier not provided.', 'cliq-store-locator'), 400);
        }

        if (! $this->provider->delete($identifier)) {
            return $this->error('cliqthemes_ui_delete_failed', __('Unable to delete record.', 'cliq-store-locator'), 404);
        }

        $this->action('cliqthemes_ui_rest_deleted', ['id' => $identifier]);

        return $this->success([
            'provider' => $this->providerId,
            'deleted' => true,
        ]);
    }

    /**
     * Apply a framework REST filter. Fires a generic hook AND a provider-scoped
     * variant (`{hook}/{providerId}`) so an extension can target every collection
     * or just one. No-ops outside WordPress (tests). Filter signature:
     * `( mixed $value, string $providerId, array $context )` for the generic hook,
     * `( mixed $value, array $context )` for the scoped variant.
     *
     * @param mixed                $value
     * @param array<string, mixed> $context
     * @return mixed
     */
    private function filter(string $hook, $value, array $context = [])
    {
        if (! function_exists('apply_filters')) {
            return $value;
        }
        $value = apply_filters($hook, $value, $this->providerId, $context);

        return apply_filters($hook . '/' . $this->providerId, $value, $context);
    }

    /**
     * Fire a framework REST action (generic + provider-scoped variant).
     *
     * @param array<string, mixed> $args
     */
    private function action(string $hook, array $args = []): void
    {
        if (! function_exists('do_action')) {
            return;
        }
        do_action($hook, $this->providerId, $args);
        do_action($hook . '/' . $this->providerId, $args);
    }

    /**
     * Extracts an identifier from the REST request using both modern and legacy parameter names.
     */
    private function identifierFromRequest(WP_REST_Request $request): mixed
    {
        $value = $request->get_param('record_id');
        if ($value !== null) {
            return $value;
        }

        return $request->get_param('id');
    }

    /**
     * Reads request bodies regardless of whether JSON, form-data or query parameters were used.
     *
     * @return array<string,mixed>
     */
    private function readPayload(WP_REST_Request $request): array
    {
        $json = $request->get_json_params();
        if (is_array($json) && $json !== []) {
            return $json;
        }

        $body = $request->get_body_params();
        if (is_array($body) && $body !== []) {
            return $body;
        }

        $paramPayload = $request->get_param('payload');
        if (is_array($paramPayload)) {
            return $paramPayload;
        }

        return [];
    }

    /**
     * Ensures providers can return any iterable while the REST API always emits a numerically indexed array.
     *
     * @param iterable<array<string,mixed>>|array<int,array<string,mixed>> $records
     *
     * @return array<int,array<string,mixed>>
     */
    private function normaliseIterable(iterable $records): array
    {
        if (is_array($records)) {
            return array_values($records);
        }

        if ($records instanceof Traversable) {
            return array_values(iterator_to_array($records, false));
        }

        return [];
    }

    /**
     * Builds a success payload compatible with both WordPress and non-WordPress contexts.
     *
     * @param array<string,mixed> $data
     *
     * @return array<string,mixed>|WP_REST_Response
     */
    private function success(array $data, int $status = 200): mixed
    {
        $data['status'] = $status;

        if (class_exists(WP_REST_Response::class)) {
            $response = rest_ensure_response($data);
            $response->set_status($status);

            return $response;
        }

        return $data;
    }

    /**
     * Builds an error response normalised for WordPress and fallback contexts.
     *
     * @return array<string,mixed>|WP_Error
     */
    private function error(string $code, string $message, int $status): mixed
    {
        if (class_exists(WP_Error::class)) {
            return new WP_Error($code, $message, ['status' => $status]);
        }

        return [
            'error' => true,
            'code' => $code,
            'message' => $message,
            'status' => $status,
        ];
    }
}
