<?php

namespace BexioConnectorByHelvy;

defined('ABSPATH') || exit;

class API
{
    protected $api_base_url;
    protected $bearer_token;
    protected $batch_mode = false;

    /**
     * Whether a timed-out broker call may be retried. OFF by default so the inline
     * order-lifecycle path (fired during checkout / status changes) never blocks a shopper for
     * a multiple of the timeout. Manual UI syncs opt in via set_allow_retry(true) — there an
     * admin is waiting on an AJAX call and one retry is worth it. Retries are safe regardless
     * because broker syncs are idempotent (server-enforced SyncRecord dedup).
     */
    protected $allow_retry = false;

    /**
     * Timeout (seconds) for broker calls. WordPress' wp_remote_* default is only 5s, which is
     * too tight: a single broker->bexio operation (contact search, invoice/PDF creation) can
     * legitimately take longer, and hitting the 5s wall surfaces as
     * "cURL error 28: Connection timed out after 5002 milliseconds".
     */
    const REQUEST_TIMEOUT = 20;
    const AUTH_TIMEOUT = 15;
    const MAX_TIMEOUT_RETRIES = 1;

    use Singleton;

    /**
     * Allow one retry on transport-level timeouts for the current request lifecycle. Only the
     * manual UI sync handlers should enable this; the inline order path must stay off.
     */
    public function set_allow_retry($on)
    {
        $this->allow_retry = (bool) $on;
    }

    /**
     * Toggle batch-sync mode. While on, requests carry the X-Bexio-Batch-Sync header so the
     * broker treats created orders as paid batch syncs (quota-exempt, flagged isBatchSync).
     */
    public function set_batch_mode($on)
    {
        $this->batch_mode = (bool) $on;
    }

    /**
     * Returns the API domain.
     */
    public static function getApiDomain()
    {
        if (defined('BEXIO_CONNECTOR_BY_HELVY_ENV')) {
            if (BEXIO_CONNECTOR_BY_HELVY_ENV == "dev") {
                return 'http://192.168.64.1:3000';
            } else if (BEXIO_CONNECTOR_BY_HELVY_ENV == "stage") {
                return 'https://dev-wc-bexio-connector-api.helvy.ch';
            }
        }
        return 'https://wc-bexio-connector-api.helvy.ch';
    }

    /**
     * Returns the API URL.
     */
    private static function getApiUrl()
    {
        return self::getApiDomain() . '/v1';
    }

    /**
     * Constructs a new instance of this class.
     */
    protected function __construct()
    {
        $this->api_base_url = self::getApiUrl();
        $this->set_authorization(get_option("bexio_connector_by_helvy_api_access_token"));
    }

    protected function set_authorization($bearer_token)
    {
        $this->bearer_token = $bearer_token;
    }

    protected function get_authorization()
    {
        return $this->bearer_token;
    }

    /**
     * Whether the current broker access token (a JWT) expires within $buffer_seconds.
     * Decodes the JWT `exp` claim locally — no broker round-trip, and works against any broker
     * version. Returns false when there is no token or the claim can't be read (then we simply
     * fall back to the reactive 401 refresh).
     */
    protected function access_token_is_expiring($buffer_seconds = 120)
    {
        $token = $this->get_authorization();
        if (empty($token)) {
            return false;
        }
        $parts = explode('.', $token);
        if (count($parts) < 2) {
            return false;
        }
        $payload = base64_decode(strtr($parts[1], '-_', '+/'));
        if ($payload === false) {
            return false;
        }
        $claims = json_decode($payload, true);
        if (!is_array($claims) || empty($claims['exp'])) {
            return false;
        }
        return ((int) $claims['exp'] - time()) < $buffer_seconds;
    }

    /**
     * Fetch the broker's single connection-health view for this shop
     * (broker / bexio / billing). See GET /v1/health/connection.
     */
    public function get_connection_health()
    {
        return $this->request('GET', '/health/connection');
    }

    /**
     * Register a new customer.
     *
     * @param  array $data The customer's data.
     * @return array
     */
    public function register_customer($data)
    {
        return $this->request('POST', '/auth/register', $data);
    }

    /**
     * Recover an existing customer account or create a new one.
     * If the hostname already exists, resets the password and returns new tokens.
     * If the hostname doesn't exist, creates a new account.
     *
     * @param  array $data The customer's data.
     * @return array
     */
    public function recover_customer($data)
    {
        return $this->unauthenticated_request('POST', '/auth/recover', $data);
    }

    /**
     * Connect the currently logged in customer with bexio.
     * Fetches the authorization URL from bexio auth provider.
     *
     * @return array
     */
    public function connect_to_bexio()
    {
        return $this->request('GET', '/auth/bexio');
    }

    /**
     * Get current usage quota
     * 
     * @return array Usage quota
     */
    public function get_usage_quota() {
        return $this->request('GET', '/payment-provider/usage-quota');
    }

    /**
     * Creates a checkout for the given subscription plan
     * 
     * @return array Checkout data
     */
    public function get_checkout($variantId) {
        return $this->request('GET', '/payment-provider/checkout/'.$variantId);
    }

    /**
     * Retrieves the customer portal URL of the currently logged in user.
     * 
     * @return array Customer portal url (in the "URL" key)
     */
    public function get_customer_portal_url() {
        return $this->request('GET', '/payment-provider/portal');
    }

    /**
     * Retrieves the user's bexio settings in batch:
     * Taxes, currencies, languages, users, accounts, etc.
     *
     * @return array
     */
    public function get_bexio_settings()
    {
        return $this->request('GET', '/bexio/settings');
    }

    /**
     * Retrieves contacts from bexio by email, or if not found, by first/last name combination.
     * 
     * @param string $email Email of the contact to retrieve
     * @param string $first_name First name of the contact to retrieve
     * @param string $last_name Last name of the contact to retrieve
     * @return array List of bexio contacts
     */
    public function get_contacts_by_email_or_name($email, $first_name, $last_name) {
        return $this->request('GET', '/bexio/contacts/by-email-or-name', [
            "email" => $email,
            "firstName" => $first_name,
            "lastName" => $last_name,
        ]);
    }

    /**
     * Find or create a country in bexio based on the given country code.
     * 
     * @param string $country_code Code of the country to find or create
     * @return array Bexio country
     */
    public function find_or_create_country_by_country_code($country_code) {
        return $this->request('GET', '/bexio/countries/find-or-create', ["countryCode" => $country_code]);
    }

    /**
     * Create a contact in bexio.
     * 
     * @param array $data Data of the contact to create
     * @return array Created bexio contact
     */
    public function create_contact($data) {
        return $this->request('POST', '/bexio/contacts', $data);
    }
            

    /**
     * Get a contact from bexio by their ID.
     * 
     * @param string $id ID of the contact to retrieve
     * @return array Bexio contact
     */
    public function get_contact_by_id($id) {
        return $this->request('GET', '/bexio/contacts/'.$id);
    }

    /**
     * Update a contact in bexio by their ID.
     * 
     * @param string $id ID of the contact to update
     * @param array $data Data of the contact to update
     * @return array Updated bexio contact
     */
    public function update_contact_by_id($id, $data) {
        return $this->request('PATCH', '/bexio/contacts/'.$id, $data);
    }

    /**
     * Search for contact relations in bexio.
     * 
     * @param array $data Search data
     * @return array Matching contact relations
     */
    public function search_contact_relations($data) {
        return $this->request('POST', '/bexio/contacts/search-relations', $data);
    }

    /**
     * Create contact relation in bexio.
     * 
     * @param string $contact_id ID of the contact
     * @param string $contact_sub_id ID of the contact sub
     * @return array Created contact relation
     */
    public function create_contact_relation($contact_id, $contact_sub_id) {
        return $this->request('POST', '/bexio/contacts/create-relation', [
            "contactId" => $contact_id,
            "contactSubId" => $contact_sub_id,
        ]);
    }

    /**
     * Get companies contacts from bexio by name and postcode.
     * 
     * @param string $name Name of the company to search
     * @param string $postcode Postcode of the company to search
     * @return array Matching companies contacts
     */
    public function get_contacts_companies_by_name_and_postcode($name, $postcode) {
        return $this->request('GET', '/bexio/contacts/companies-by-name-and-postcode', [
            "name" => $name,
            "postcode" => $postcode
        ]);
    }

    /**
     * Get additional addresses of a bexio contact which have the given subject
     * 
     * @param string $contact_id ID of the contact
     * @param array $subject Subject of the address(es) to fetch
     * @return array Matching addresses
     */
    public function get_contact_additional_addresses_by_subject($contact_id, $subject) {
        return $this->request('GET', '/bexio/contacts/'.$contact_id.'/additional-addresses', ["subject" => $subject]);
    }
    
    /**
     * Updates an additional address of a bexio contact
     * 
     * @param string $contact_id ID of the contact
     * @param string $address_id ID of the address to update
     * @param array $data Data of the address to update
     * @return array Updated address
     */
    public function update_contact_additional_address($contact_id, $address_id, $data) {
        return $this->request('PATCH', '/bexio/contacts/'.$contact_id.'/additional-addresses/'.$address_id, $data);
    }

    /**
     * Creates an additional address for a bexio contact
     * 
     * @param string $contact_id ID of the contact
     * @param array $data Data of the address
     * @return array Created address
     */
    public function create_contact_additional_addresses($contact_id, $data) {
        return $this->request('POST', '/bexio/contacts/'.$contact_id.'/additional-addresses', $data);
    }

    /**
     * Create an article in bexio
     * 
     * @param array $article Article to create
     * @return array Created article
     */
    public function create_article($article) {
        return $this->request('POST', '/bexio/articles/', $article);
    }

    /**
     * Update an article in bexio
     * 
     * @param array $article_id ID of the article to update
     * @param array $data Data of the article to update (patch)
     * @return array Updated article
     */
    public function update_article($article_id, $data) {
        return $this->request('POST', '/bexio/articles/'.$article_id, $data);
    }

    /**
     * Retrieves an article from bexio based on its ID
     * 
     * @param string $id ID of the article to retrieve
     * @return array Found bexio article
     */
    public function get_article_by_id($id) {
        return $this->request('GET', '/bexio/articles/'.$id);
    }

    /**
     * Retrieves an article from bexio based on its SKU
     * 
     * @param string $sku SKU of the article to retrieve
     * @return array Found bexio article
     */
    public function get_article_by_sku($sku) {
        $articles = $this->request('GET', '/bexio/articles', ["sku" => $sku]);
        if (!($articles instanceof \WP_Error) && count($articles)) {
            return $articles[0];
        }
    }

    /**
     * Retrieves all currencies from bexio
     * 
     * @return array List of bexio currencies
     */
    public function get_currencies() {
        return $this->request('GET', '/bexio/currencies');
    }

    /**
     * Create a new currency in bexio
     * 
     * @param string $name Name of the currency to create (usually ISO 4217)
     * @return array Created currency
     */
    public function create_currency($name) {
        return $this->request('POST', '/bexio/currencies', ["name" => $name]);
    }

    /**
     * Create a new order in bexio
     * 
     * @param string $data Order's data
     * @return array Created order
     */
    public function create_order($data) {
        return $this->request('POST', '/bexio/orders', $data);
    }

    /**
     * Get an order by ID from bexio
     * 
     * @param int $order_id Order's ID
     * @return array Order resource
     */
    public function get_order_by_id($order_id) {
        return $this->request('GET', '/bexio/orders/'.$order_id);
    }

    /**
     * Create a new invoice based on the given order in bexio
     * 
     * @param string $order_id Order from which to create the invoice
     * @return array Created invoice
     */
    public function create_invoice_from_order($order_id) {
        return $this->request('POST', '/bexio/orders/'.$order_id.'/invoice');
    }

    /**
     * Get an invoice by ID from bexio
     * 
     * @param int $invoice_id Order's ID
     * @return array Order resource
     */
    public function get_invoice_by_id($invoice_id) {
        return $this->request('GET', '/bexio/invoices/'.$invoice_id);
    }

    /**
     * Update an invoice in bexio
     * 
     * @param string $invoice_id Invoice to update
     * @param array $data Data of the invoice to update
     * @return array Updated invoice
     */
    public function update_invoice($invoice_id, $data) {
        return $this->request('PATCH', '/bexio/invoices/'.$invoice_id, $data);
    }

    /**
     * Issue an invoice in bexio
     * 
     * @param string $invoice_id Invoice to issue
     * @return array Issued invoice
     */
    public function issue_invoice($invoice_id) {
        return $this->request('POST', '/bexio/invoices/'.$invoice_id.'/issue');
    }

    /**
     * Retrieve the PDF of the given invoice in bexio
     * 
     * @param string $invoice_id Invoice from which to retrieve the PDF
     * @return array Invoice PDF
     */
    public function get_invoice_pdf($invoice_id) {
        return $this->request('GET', '/bexio/invoices/'.$invoice_id.'/pdf');
    }

    /**
     * Retrieve the PDF of the given order in bexio
     * 
     * @param string $order_id Order from which to retrieve the PDF
     * @return array Order PDF
     */
    public function get_order_pdf($order_id) {
        return $this->request('GET', '/bexio/orders/'.$order_id.'/pdf');
    }

    /**
     * Create a delivery from the given order in bexio
     * 
     * @param string $order_id Order from which to create the delivery
     * @return array Created delivery
     */
    public function create_delivery_from_order($order_id) {
        return $this->request('POST', '/bexio/orders/'.$order_id.'/delivery');
    }

    /**
     * Get payments of the given bexio invoice
     * 
     * @param string $invoice_id Invoice from which to retrieve the payments
     * @return array Payments list
     */
    public function get_invoice_payments($invoice_id) {
        return $this->request('GET', '/bexio/invoices/'.$invoice_id.'/payments');
    }

    /**
     * Create payment for the given bexio invoice
     * 
     * @param string $invoice_id Invoice for which to create the payment
     * @param array $data Payment's data
     * @return array Created payment
     */
    public function create_invoice_payment($invoice_id, $data) {
        return $this->request('POST', '/bexio/invoices/'.$invoice_id.'/payments', $data);
    }

    /**
     * Issue the given bexio delivery
     * 
     * @param string $delivery_id Delivery to issue
     * @return array Issued delivery
     */
    public function issue_delivery($delivery_id) {
        return $this->request('POST', '/bexio/deliveries/'.$delivery_id.'/issue');
    }

    /**
     * List the current user's subscriptions
     * 
     * @return array Subscriptions
     */
    public function get_current_subscriptions() {
        return $this->request('GET', '/payment-provider/subscriptions');
    }

    /**
     * Update the current user's subscription
     * 
     * @param string $variantId Variant ID of the subscription to cancel
     * @return array Subscription
     */
    public function update_current_subscriptions($variantId) {
        return $this->request('PATCH', '/payment-provider/subscriptions', ["variantId" => $variantId]);
    }

    /**
     * Cancel the current user's subscription (identified by its variant id)
     * 
     * @return array Subscription
     */
    public function delete_current_subscriptions() {
        return $this->request('DELETE', '/payment-provider/subscriptions');
    }

    /**
     * List the available subscription plans
     * 
     * @return array Plans
     */
    public function get_subscription_plans() {
        return $this->request('GET', '/payment-provider/plans');
    }

    /**
     * Price a batch sync of past orders. $data = ["fromOrder", "toOrder", "orders" => [{id,name,date}]]
     */
    public function batch_sync_preview($data)
    {
        return $this->request('POST', '/batch-sync/preview', $data);
    }

    /**
     * Create the one-time checkout for a priced batch sync.
     */
    public function batch_sync_checkout($batch_sync_id)
    {
        return $this->request('POST', '/batch-sync/' . $batch_sync_id . '/checkout');
    }

    /**
     * Get a batch sync (status, pricing, outcome).
     */
    public function batch_sync_get($batch_sync_id)
    {
        return $this->request('GET', '/batch-sync/' . $batch_sync_id);
    }

    /**
     * List the shop's batch syncs (paginated).
     */
    public function batch_sync_list($page = 1)
    {
        return $this->request('GET', '/batch-sync', ["page" => $page]);
    }

    /**
     * Report batch-sync execution progress/outcome back to the broker.
     */
    public function batch_sync_update_progress($batch_sync_id, $data)
    {
        return $this->request('PATCH', '/batch-sync/' . $batch_sync_id . '/progress', $data);
    }

    /**
     * wp_remote_request with an explicit timeout and — only when retries are enabled
     * (set_allow_retry) — a bounded retry on transport-level timeouts (cURL 28 & friends). Real
     * HTTP responses, including 4xx/5xx, are returned to the caller on the first attempt; only
     * connection timeouts are retried. Retries stay OFF on the inline order path so a checkout
     * is never blocked for a multiple of the timeout.
     */
    protected function remote_request($url, $args, $timeout = self::REQUEST_TIMEOUT)
    {
        if (!isset($args['timeout'])) {
            $args['timeout'] = apply_filters('bexio_connector_by_helvy_request_timeout', $timeout, $url);
        }

        $max_retries = $this->allow_retry ? self::MAX_TIMEOUT_RETRIES : 0;

        $attempt = 0;
        do {
            $response = wp_remote_request($url, $args);
            if (!is_wp_error($response) || !$this->is_timeout_error($response)) {
                return $response;
            }
            if ($attempt < $max_retries) {
                _bcbh_log("Broker request timed out ({$url}): " . $response->get_error_message()
                    . " — retry " . ($attempt + 1) . "/" . $max_retries, 'WARN');
                sleep(1);
            }
            $attempt++;
        } while ($attempt <= $max_retries);

        return $response;
    }

    /**
     * Whether a WP_Error from wp_remote_* is a transport-level timeout/connection failure
     * (cURL 28 etc.), as opposed to an application/HTTP error.
     */
    protected function is_timeout_error($wp_error)
    {
        $message = strtolower($wp_error->get_error_message());
        return (
            strpos($message, 'timed out') !== false ||
            strpos($message, 'timeout') !== false ||
            strpos($message, 'curl error 28') !== false
        );
    }

    /**
     * Sends an unauthenticated request to the remote API.
     * Used for endpoints that don't require a bearer token (e.g. registration, recovery).
     */
    protected function unauthenticated_request($method = 'POST', $route = '', $data = array())
    {
        add_filter('https_ssl_verify', '__return_false');
        add_filter('https_local_ssl_verify', '__return_false');

        $url = $this->api_base_url . '/' . ltrim($route, '/');
        $args = array(
            'method' => strtoupper($method),
            'headers' => array(
                'Content-Type' => 'application/json',
            ),
        );

        if (!empty($data)) {
            if ($method === 'GET') {
                $url = add_query_arg(array_map('rawurlencode', $data), $url);
            } else {
                $args['body'] = wp_json_encode($data);
            }
        }

        $response = $this->remote_request($url, $args);
        if (is_wp_error($response)) {
            return new \WP_Error('request_failed', $response->get_error_message());
        }

        $response_code = wp_remote_retrieve_response_code($response);
        $response_body = wp_remote_retrieve_body($response);
        $response_data = json_decode($response_body, true);

        if ($response_code >= 200 && $response_code < 300) {
            return $response_data;
        } else {
            return new \WP_Error('api_error', 'The API request returned an error', array(
                'status_code' => $response_code,
                'response' => $response_data,
            ));
        }
    }

    /**
     * Sends a request to the remote API.
     */
    protected function request($method = 'GET', $route = '', $data = array())
    {
        add_filter('https_ssl_verify', '__return_false');
        add_filter('https_local_ssl_verify', '__return_false');

        $url = $this->api_base_url . '/' . ltrim($route, '/');

        // Proactively refresh the broker JWT when it is about to expire, so this call doesn't
        // race a 401. refresh_token()/login() use wp_remote_post directly (not request()), so
        // there is no recursion. Best-effort: the reactive 401 path below remains the backstop.
        if ($this->access_token_is_expiring()) {
            $this->refresh_token();
        }

        $headers = array(
            'Authorization' => 'Bearer ' . $this->get_authorization(),
            'Content-Type' => 'application/json',
        );
        if ($this->batch_mode) {
            $headers['X-Bexio-Batch-Sync'] = '1';
        }
        $args = array(
            'method' => strtoupper($method),
            'headers' => $headers,
        );

        if (!empty($data)) {
            if ($method === 'GET') {
                $url = add_query_arg(array_map('rawurlencode', $data), $url);
            } else {
                $args['body'] = wp_json_encode($data);
            }
        }

        $response = $this->remote_request($url, $args);
        if (is_wp_error($response)) {
            return new \WP_Error('request_failed', $response->get_error_message());
        }

        $response_code = wp_remote_retrieve_response_code($response);
        $response_body = wp_remote_retrieve_body($response);
        $response_data = json_decode($response_body, true);

        if (!empty($response_data["error_code"]) || !empty($response_data["errors"])) {
            if (!empty($response_data["errors"])) {
                return new \WP_Error('request_failed', 'bexio API error: ' . join(", ", $response_data["errors"]));
            } elseif (!empty($response_data["message"])) {
                return new \WP_Error('request_failed', 'bexio API error: ' . $response_data["message"]);
            } else {
                return new \WP_Error('request_failed', 'The API request to bexio failed');
            }
        }

        if ($response_code >= 200 && $response_code < 300) {
            return $response_data;
        } elseif ($response_code === 401) {
            // Refresh the token and retry the request
            $refresh_result = $this->refresh_token();
            if (is_wp_error($refresh_result)) {
                return $refresh_result;
            }
            // Retry the request with the new token
            $args['headers']['Authorization'] = 'Bearer ' . $this->get_authorization();
            $response = $this->remote_request($url, $args);
            if (is_wp_error($response)) {
                return new \WP_Error('request_failed', $response->get_error_message());
            }

            $response_code = wp_remote_retrieve_response_code($response);
            $response_body = wp_remote_retrieve_body($response);
            $response_data = json_decode($response_body, true);

            if ($response_code >= 200 && $response_code < 300) {
                return $response_data;
            } else {
                return new \WP_Error('api_error', 'The API request returned an error', array(
                    'status_code' => $response_code,
                    'response' => $response_data,
                ));
            }
        } else {
            return new \WP_Error('api_error', 'The API request returned an error', array(
                'status_code' => $response_code,
                'response' => $response_data,
            ));
        }
    }

    /**
     * Refreshes the tokens set with the refresh token
     */
    protected function refresh_token()
    {
        $url = $this->api_base_url . '/auth/refresh-tokens';
        $args = array(
            'method' => 'POST',
            'headers' => array(
                'Content-Type' => 'application/json',
            ),
            'body' => wp_json_encode(array(
                'refreshToken' => get_option("bexio_connector_by_helvy_api_refresh_token"),
            )),
        );

        $response = $this->remote_request($url, $args, self::AUTH_TIMEOUT);
        if (is_wp_error($response)) {
            return new \WP_Error('refresh_failed', 'The token refresh request failed', $response->get_error_message());
        }

        $response_code = wp_remote_retrieve_response_code($response);
        $response_body = wp_remote_retrieve_body($response);
        $response_data = json_decode($response_body, true);

        if ($response_code >= 200 && $response_code < 300 && isset($response_data['access']['token'])) {
            // Save the new access token
            $this->set_authorization($response_data['access']['token']);
            update_option("bexio_connector_by_helvy_api_access_token", $response_data['access']['token']);
            update_option("bexio_connector_by_helvy_api_refresh_token", $response_data['refresh']['token']);
            return true;
        } else {
            return $this->login();
            // return new \WP_Error('refresh_failed', 'The token refresh request failed', array(
            //     'status_code' => $response_code,
            //     'response' => $response_data,
            // ));
        }
    }

    /**
     * Login the customer
     */
    protected function login()
    {
        $url = $this->api_base_url . '/auth/login';
        $args = array(
            'method' => 'POST',
            'headers' => array(
                'Content-Type' => 'application/json',
            ),
            'body' => wp_json_encode(array(
                'hostname' => Utils::extract_domain(get_site_url()),
                'password' => get_option("bexio_connector_by_helvy_api_hash"),
            )),
        );

        $response = $this->remote_request($url, $args, self::AUTH_TIMEOUT);
        if (is_wp_error($response)) {
            return new \WP_Error('refresh_failed', 'Invalid credentials', $response->get_error_message());
        }

        $response_code = wp_remote_retrieve_response_code($response);
        $response_body = wp_remote_retrieve_body($response);
        $response_data = json_decode($response_body, true);

        if ($response_code >= 200 && $response_code < 300 && isset($response_data['tokens']['access']['token'])) {
            // Save the tokens
            $this->set_authorization($response_data['tokens']['access']['token']);
            update_option("bexio_connector_by_helvy_api_access_token", $response_data['tokens']['access']['token']);
            update_option("bexio_connector_by_helvy_api_refresh_token", $response_data['tokens']['refresh']['token']);
            return true;
        } else {
            return new \WP_Error('refresh_failed', 'Invalid credentials', array(
                'status_code' => $response_code,
                'response' => $response_data,
            ));
        }
    }
}
