<?php

namespace BexioConnectorByHelvy;

/**
 * Batch Sync — paid one-time bulk sync of past WooCommerce orders to bexio.
 *
 * The broker owns the BatchSync record (estimate/pricing/payment/outcome). This class:
 *  - resolves which past orders an order-number range covers,
 *  - exposes REST endpoints the admin UI uses (proxying the broker for pricing/payment),
 *  - runs the actual per-order sync in the background via Action Scheduler (reusing the same
 *    WCToBexio workflow as real-time sync, in batch mode so the broker keeps it quota-exempt),
 *  - reports progress/outcome back to the broker.
 *
 * @since 0.5.0
 */

defined('ABSPATH') || exit;

class BatchSync
{
    const RUN_HOOK = 'bexio_batch_sync_run';
    const CHUNK_SIZE = 5;            // orders processed per Action Scheduler pass
    const PER_ORDER_SLEEP_US = 800000; // 0.8s between orders (rate limiting)
    const AS_GROUP = 'bexio-batch-sync';

    public function __construct()
    {
        add_action('rest_api_init', array($this, 'register_routes'));
        add_action(self::RUN_HOOK, array($this, 'run_batch_chunk'), 10, 1);
    }

    public function register_routes()
    {
        $ns = BEXIO_CONNECTOR_BY_HELVY_REST_API_ROUTE;
        register_rest_route($ns, '/batch-sync', array(
            'methods' => 'GET',
            'callback' => array($this, 'list_batch_syncs'),
            'permission_callback' => array($this, 'get_permission'),
        ));
        register_rest_route($ns, '/batch-sync/preview', array(
            'methods' => 'POST',
            'callback' => array($this, 'preview'),
            'permission_callback' => array($this, 'get_permission'),
        ));
        register_rest_route($ns, '/batch-sync/(?P<id>[a-zA-Z0-9]+)', array(
            'methods' => 'GET',
            'callback' => array($this, 'get_batch_sync'),
            'permission_callback' => array($this, 'get_permission'),
        ));
        register_rest_route($ns, '/batch-sync/(?P<id>[a-zA-Z0-9]+)/checkout', array(
            'methods' => 'POST',
            'callback' => array($this, 'checkout'),
            'permission_callback' => array($this, 'get_permission'),
        ));
    }

    public function get_permission()
    {
        return current_user_can('administrator') || current_user_can('manage_woocommerce');
    }

    /**
     * Surface the broker's actual error message (it's nested in the WP_Error data) instead of
     * the generic "The API request returned an error".
     */
    private function error_message($wp_error)
    {
        $data = $wp_error->get_error_data();
        if (is_array($data) && !empty($data['response']['message'])) {
            return $data['response']['message'];
        }
        return $wp_error->get_error_message();
    }

    // ---------------------------------------------------------------------
    // REST handlers
    // ---------------------------------------------------------------------

    public function list_batch_syncs($request)
    {
        $page = max(1, intval($request->get_param('page') ?: 1));
        $res = API::instance()->batch_sync_list($page);
        if ($res instanceof \WP_Error) {
            return new \WP_REST_Response(array('error' => $this->error_message($res)), 500);
        }
        return new \WP_REST_Response($res, 200);
    }

    public function preview($request)
    {
        $body = $request->get_json_params();
        $from = isset($body['fromOrder']) ? trim((string) $body['fromOrder']) : '';
        $to = isset($body['toOrder']) ? trim((string) $body['toOrder']) : '';
        if ($from === '' || $to === '') {
            return new \WP_REST_Response(array('error' => 'Please provide both order numbers'), 400);
        }

        $orders = $this->resolve_orders_in_range($from, $to);
        if ($orders instanceof \WP_Error) {
            return new \WP_REST_Response(array('error' => $orders->get_error_message()), 400);
        }
        if (empty($orders)) {
            return new \WP_REST_Response(array('error' => 'No un-synced orders found in that range'), 400);
        }

        $res = API::instance()->batch_sync_preview(array(
            'fromOrder' => $from,
            'toOrder' => $to,
            'orders' => $orders,
        ));
        if ($res instanceof \WP_Error) {
            return new \WP_REST_Response(array('error' => $this->error_message($res)), 500);
        }
        return new \WP_REST_Response($res, 200);
    }

    public function checkout($request)
    {
        $id = $request->get_param('id');
        $res = API::instance()->batch_sync_checkout($id);
        if ($res instanceof \WP_Error) {
            return new \WP_REST_Response(array('error' => $this->error_message($res)), 500);
        }
        return new \WP_REST_Response($res, 200);
    }

    /**
     * Proxy the broker batch record. When it has just been paid (READY), kick off the
     * background job so execution starts even if the merchant closes the tab.
     */
    public function get_batch_sync($request)
    {
        $id = $request->get_param('id');
        $res = API::instance()->batch_sync_get($id);
        if ($res instanceof \WP_Error) {
            return new \WP_REST_Response(array('error' => $this->error_message($res)), 500);
        }
        if (isset($res['status']) && $res['status'] === 'READY') {
            $this->maybe_start_batch($res);
        }
        return new \WP_REST_Response($res, 200);
    }

    // ---------------------------------------------------------------------
    // Order selection
    // ---------------------------------------------------------------------

    /**
     * Resolve the un-synced orders covered by an order-number range into [{id,name,date}].
     */
    private function resolve_orders_in_range($from, $to)
    {
        $from_order = $this->resolve_order($from);
        $to_order = $this->resolve_order($to);
        if (!$from_order || !$to_order) {
            return new \WP_Error('invalid_range', 'Could not find the orders for the given numbers');
        }

        $from_ts = $from_order->get_date_created() ? $from_order->get_date_created()->getTimestamp() : 0;
        $to_ts = $to_order->get_date_created() ? $to_order->get_date_created()->getTimestamp() : 0;
        if ($from_ts > $to_ts) {
            $tmp = $from_ts; $from_ts = $to_ts; $to_ts = $tmp;
        }

        // Use the same order statuses the plugin actually syncs, so the batch selection matches
        // what will really be synced (and the price reflects it). Falls back to a sensible default.
        $statuses = get_option(AdminAPISettings::$prefix . 'sync_orders_statuses');
        if (!is_array($statuses) || empty($statuses)) {
            $statuses = array('pending', 'on-hold', 'processing', 'completed');
        }

        $order_ids = wc_get_orders(array(
            'limit' => -1,
            'return' => 'ids',
            'orderby' => 'date',
            'order' => 'ASC',
            'status' => $statuses,
            'date_created' => $from_ts . '...' . $to_ts,
        ));

        $prefix = AdminAPISettings::$prefix;
        $result = array();
        foreach ($order_ids as $order_id) {
            // Skip orders already synced to bexio.
            if (get_post_meta($order_id, $prefix . 'bexio_order_id', true)) {
                continue;
            }
            $order = wc_get_order($order_id);
            if (!$order) {
                continue;
            }
            // Skip zero-total orders (bexio rejects free invoices).
            if (floatval($order->get_total()) <= 0) {
                continue;
            }
            $created = $order->get_date_created();
            $result[] = array(
                'id' => (string) $order_id,
                'name' => $order->get_order_number(),
                'date' => $created ? $created->date('c') : null,
            );
        }
        return $result;
    }

    /**
     * Resolve a user-entered order number to a WC_Order. Tries the id directly (the common
     * case where order number == id), then a bounded search by display order number.
     */
    private function resolve_order($input)
    {
        $clean = ltrim(trim((string) $input), '#');
        if (is_numeric($clean)) {
            $order = wc_get_order(intval($clean));
            if ($order) {
                return $order;
            }
        }
        // Fallback: search recent orders for a matching display number (custom numbering plugins).
        $candidates = wc_get_orders(array('limit' => 2000, 'return' => 'ids', 'orderby' => 'date', 'order' => 'DESC'));
        foreach ($candidates as $order_id) {
            $order = wc_get_order($order_id);
            if ($order && ltrim($order->get_order_number(), '#') === $clean) {
                return $order;
            }
        }
        return null;
    }

    // ---------------------------------------------------------------------
    // Background execution (Action Scheduler)
    // ---------------------------------------------------------------------

    private function progress_option_key($batch_id)
    {
        return AdminAPISettings::$prefix . 'batch_progress_' . $batch_id;
    }

    /**
     * Initialise local progress from the paid broker record and schedule the first chunk.
     */
    private function maybe_start_batch($batch)
    {
        $batch_id = isset($batch['id']) ? $batch['id'] : null;
        if (!$batch_id) {
            return;
        }
        if ($this->is_scheduled($batch_id)) {
            return; // already running
        }
        $key = $this->progress_option_key($batch_id);
        if (get_option($key) !== false) {
            return; // already started (and possibly finished)
        }

        $order_ids = array();
        foreach ((array) ($batch['orders'] ?? array()) as $o) {
            if (isset($o['id'])) {
                $order_ids[] = (string) $o['id'];
            }
        }
        update_option($key, array(
            'order_ids' => $order_ids,
            'index' => 0,
            'outcome' => array(),
            'nbSuccess' => 0,
            'nbErrors' => 0,
        ), false);

        $this->schedule_next($batch_id);
    }

    private function is_scheduled($batch_id)
    {
        if (function_exists('as_has_scheduled_action')) {
            return as_has_scheduled_action(self::RUN_HOOK, array($batch_id), self::AS_GROUP);
        }
        return (bool) wp_next_scheduled(self::RUN_HOOK, array($batch_id));
    }

    private function schedule_next($batch_id)
    {
        if (function_exists('as_schedule_single_action')) {
            as_schedule_single_action(time() + 1, self::RUN_HOOK, array($batch_id), self::AS_GROUP);
        } else {
            wp_schedule_single_event(time() + 1, self::RUN_HOOK, array($batch_id));
        }
    }

    /**
     * Process one chunk of the batch, then reschedule the next chunk or finish.
     */
    public function run_batch_chunk($batch_id)
    {
        $key = $this->progress_option_key($batch_id);
        $progress = get_option($key);
        if (!is_array($progress)) {
            _bcbh_log("[BatchSync] No progress state for batch {$batch_id}; aborting.");
            return;
        }

        $order_ids = $progress['order_ids'];
        $index = intval($progress['index']);
        $total = count($order_ids);

        $processed = 0;
        while ($index < $total && $processed < self::CHUNK_SIZE) {
            $order_id = $order_ids[$index];
            $entry = $this->sync_one_order($order_id);
            $progress['outcome'][] = $entry;
            if (empty($entry['error'])) {
                $progress['nbSuccess']++;
            } else {
                $progress['nbErrors']++;
            }
            $index++;
            $processed++;
            if ($index < $total) {
                usleep(self::PER_ORDER_SLEEP_US);
            }
        }
        $progress['index'] = $index;
        update_option($key, $progress, false);

        $done = $index >= $total;
        // Report progress/outcome to the broker (source of truth).
        API::instance()->batch_sync_update_progress($batch_id, array(
            'status' => $done ? 'DONE' : 'IN_PROGRESS',
            'outcome' => $progress['outcome'],
            'nbSuccess' => $progress['nbSuccess'],
            'nbErrors' => $progress['nbErrors'],
        ));

        if ($done) {
            _bcbh_log("[BatchSync] Batch {$batch_id} finished: {$progress['nbSuccess']} ok, {$progress['nbErrors']} errors.");
            delete_option($key);
        } else {
            $this->schedule_next($batch_id);
        }
    }

    /**
     * Run the full per-order sync workflow for one order in batch mode and return an outcome
     * entry. Mirrors the manual full-sync sequence (order -> delivery -> invoice -> issue ->
     * payment -> fulfillment). Only the order step is fatal; later steps degrade to warnings.
     */
    private function sync_one_order($order_id)
    {
        $prefix = AdminAPISettings::$prefix;
        $order = wc_get_order($order_id);
        $entry = array(
            'orderId' => (string) $order_id,
            'orderName' => $order ? $order->get_order_number() : (string) $order_id,
            'bexioOrderId' => null,
            'bexioOrderNr' => null,
            'bexioInvoiceId' => null,
            'bexioInvoiceNr' => null,
            'error' => null,
            'warning' => null,
        );
        if (!$order) {
            $entry['error'] = 'Order not found';
            return $entry;
        }

        $warnings = array();
        $api = API::instance();
        $api->set_batch_mode(true);
        try {
            $res = WCToBexio::instance()->sync_order($order, true);
            if ($res instanceof \WP_Error) {
                $entry['error'] = $res->get_error_message();
            } else {
                $order = wc_get_order($order_id);
                $r = WCToBexio::instance()->sync_order_create_delivery($order_id);
                if ($r instanceof \WP_Error) { $warnings[] = 'Delivery: ' . $r->get_error_message(); }

                $r = WCToBexio::instance()->sync_order_create_invoice($order_id);
                if ($r instanceof \WP_Error) { $warnings[] = 'Invoice: ' . $r->get_error_message(); }

                if (get_post_meta($order_id, $prefix . 'bexio_invoice_id', true)) {
                    $r = WCToBexio::instance()->sync_order_issue_invoice($order_id);
                    if ($r instanceof \WP_Error) { $warnings[] = 'Issue: ' . $r->get_error_message(); }
                }
                $order = wc_get_order($order_id);
                if ($order->is_paid()) {
                    $r = WCToBexio::instance()->sync_order_payment($order, true);
                    if ($r instanceof \WP_Error) { $warnings[] = 'Payment: ' . $r->get_error_message(); }
                }
                $order = wc_get_order($order_id);
                $r = WCToBexio::instance()->sync_order_fulfillment($order, true);
                if ($r instanceof \WP_Error) { $warnings[] = 'Fulfillment: ' . $r->get_error_message(); }
            }
        } catch (\Throwable $e) {
            $entry['error'] = $e->getMessage();
        } finally {
            $api->set_batch_mode(false);
        }

        // Read resulting bexio identifiers from order meta.
        $entry['bexioOrderId'] = get_post_meta($order_id, $prefix . 'bexio_order_id', true) ?: null;
        $entry['bexioOrderNr'] = get_post_meta($order_id, $prefix . 'bexio_order_number', true) ?: null;
        $entry['bexioInvoiceId'] = get_post_meta($order_id, $prefix . 'bexio_invoice_id', true) ?: null;
        $entry['bexioInvoiceNr'] = get_post_meta($order_id, $prefix . 'bexio_invoice_number', true) ?: null;
        if (!$entry['error'] && !$entry['bexioOrderId']) {
            $entry['error'] = 'Order was not created in bexio';
        }
        if ($warnings) {
            $entry['warning'] = implode(' | ', $warnings);
        }
        return $entry;
    }
}
