<?php
declare(strict_types=1);

/**
 * Minimal Paynow Zimbabwe hosted-checkout client.
 * Integration credentials remain in config/config.php and are never returned
 * to the storefront or written to browser-visible URLs.
 */

function paynow_enabled(): bool {
    return (bool)cfg('paynow.enabled', false)
        && ctype_digit((string)cfg('paynow.integration_id', ''))
        && trim((string)cfg('paynow.integration_key', '')) !== '';
}

function paynow_hash(array $values): string {
    $joined = '';
    foreach ($values as $key => $value) {
        if (strcasecmp((string)$key, 'hash') !== 0) $joined .= (string)$value;
    }
    return strtoupper(hash('sha512', $joined . trim((string)cfg('paynow.integration_key', ''))));
}

/**
 * Build a hosted-checkout request in Paynow's documented field order.
 * The hash is order-sensitive, so all optional fields must be inserted before
 * merchanttrace and status. Keep this as the single builder for every checkout.
 */
function paynow_initiation_fields(
    string $reference,
    float $amount,
    string $additionalInfo,
    string $returnUrl,
    string $resultUrl,
    string $email = '',
    string $phone = '',
    string $name = '',
    string $merchantTrace = ''
): array {
    $fields = [
        'id' => trim((string)cfg('paynow.integration_id', '')),
        'reference' => $reference,
        'amount' => number_format($amount, 2, '.', ''),
        'additionalinfo' => $additionalInfo,
        'returnurl' => $returnUrl,
        'resulturl' => $resultUrl,
    ];
    if (trim($email) !== '') $fields['authemail'] = trim($email);
    if (trim($phone) !== '') $fields['authphone'] = trim($phone);
    if (trim($name) !== '') $fields['authname'] = trim($name);
    if (trim($merchantTrace) !== '') $fields['merchanttrace'] = trim($merchantTrace);
    $fields['status'] = 'Message';
    $fields['hash'] = paynow_hash($fields);
    return $fields;
}

function paynow_parse(string $body): array {
    $values = [];
    parse_str(trim($body), $values);
    $normalised = [];
    foreach ($values as $key => $value) $normalised[strtolower((string)$key)] = is_array($value) ? '' : (string)$value;
    return $normalised;
}

function paynow_verify(array $values): bool {
    $provided = strtoupper(trim((string)($values['hash'] ?? '')));
    return $provided !== '' && hash_equals(paynow_hash($values), $provided);
}

/**
 * Validate a transaction-initiation response without hiding Paynow's own
 * rejection reason. Successful responses must always carry a valid hash.
 * Paynow documents Error responses as Status=Error&Error=... and those
 * responses may not contain a hash, so they are diagnostic only and can never
 * create or confirm a payment.
 */
function paynow_validate_init_response(array $response): array {
    $status = strtolower(trim((string)($response['status'] ?? '')));
    if ($status === 'ok') {
        if (!paynow_verify($response)) throw new RuntimeException('Paynow returned a successful checkout response with an invalid signature.');
        return $response;
    }
    if ($status === 'error') {
        $message = trim((string)($response['error'] ?? ''));
        $message = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $message) ?? '';
        if ($message === '') $message = 'The integration details or checkout request were rejected.';
        throw new RuntimeException('Paynow rejected the checkout: ' . substr($message, 0, 240));
    }
    throw new RuntimeException('Paynow returned an unexpected checkout response.');
}

function paynow_url_allowed(string $url): bool {
    $parts = parse_url($url);
    if (!is_array($parts) || strtolower((string)($parts['scheme'] ?? '')) !== 'https') return false;
    $host = strtolower((string)($parts['host'] ?? ''));
    return $host === 'paynow.co.zw' || str_ends_with($host, '.paynow.co.zw');
}

function paynow_post(string $url, ?array $fields = null): string {
    if (!paynow_url_allowed($url)) throw new RuntimeException('Paynow returned an unsafe payment address.');
    if (!function_exists('curl_init')) throw new RuntimeException('The server PHP cURL extension is required for Paynow.');
    $curl = curl_init($url);
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $fields === null ? '' : http_build_query($fields, '', '&', PHP_QUERY_RFC3986),
        CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: text/plain'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 25,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_USERAGENT => 'SodaZW-Billing-Paynow/1.0',
    ]);
    $body = curl_exec($curl);
    $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $error = curl_error($curl);
    curl_close($curl);
    if ($body === false || $status < 200 || $status >= 300) {
        throw new RuntimeException('Paynow could not be reached securely' . ($error !== '' ? ': ' . $error : '.'));
    }
    return (string)$body;
}

function paynow_poll(string $pollUrl): array {
    $values = paynow_parse(paynow_post($pollUrl));
    if (!paynow_verify($values)) throw new RuntimeException('Paynow status verification failed.');
    return $values;
}

function paynow_checkout_for_order(int $orderId): array {
    if (!paynow_enabled()) throw new RuntimeException('Paynow checkout is not enabled yet.');
    $query = db()->prepare('SELECT w.*,d.number invoice_number FROM web_orders w LEFT JOIN documents d ON d.id=w.invoice_id WHERE w.id=? LIMIT 1');
    $query->execute([$orderId]);
    $order = $query->fetch();
    if (!$order) throw new RuntimeException('Website order not found.');
    if ($order['fulfilment_method'] === 'Delivery' && (float)$order['delivery_fee'] <= 0) {
        throw new RuntimeException('The delivery fee must be confirmed before Paynow payment can begin.');
    }
    $amount = round((float)$order['total'], 2);
    if ($amount <= 0) throw new RuntimeException('The order total is invalid.');

    $existing = db()->prepare('SELECT * FROM online_payment_attempts WHERE provider="paynow" AND web_order_id=? ORDER BY id DESC LIMIT 1');
    $existing->execute([$orderId]);
    $attempt = $existing->fetch();
    if ($attempt && !empty($attempt['browser_url']) && in_array($attempt['status'], ['created','initiated','sent'], true) && paynow_url_allowed((string)$attempt['browser_url'])) {
        return ['payment_url'=>$attempt['browser_url'], 'reference'=>$attempt['merchant_reference'], 'status'=>$attempt['status']];
    }

    $reference = (string)$order['order_number'];
    $merchantTrace = strtoupper(substr(hash('sha256', $reference), 0, 32));
    $returnUrl = url('api/paynow-return.php?reference=' . rawurlencode($reference));
    $resultUrl = url('api/paynow-result.php');
    $fields = paynow_initiation_fields(
        $reference,
        $amount,
        'Soda ZW order ' . $reference,
        $returnUrl,
        $resultUrl,
        (string)($order['email'] ?? ''),
        (string)($order['phone'] ?? ''),
        (string)($order['customer_name'] ?? ''),
        $merchantTrace
    );

    if (!$attempt) {
        db()->prepare('INSERT INTO online_payment_attempts (provider,web_order_id,invoice_id,merchant_reference,merchant_trace,amount,currency_code,status) VALUES ("paynow",?,?,?,?,?,"USD","created")')
            ->execute([$orderId, $order['invoice_id'] ?: null, $reference, $merchantTrace, $amount]);
        $attemptId = (int)db()->lastInsertId();
    } else {
        $attemptId = (int)$attempt['id'];
        db()->prepare('UPDATE online_payment_attempts SET merchant_reference=?,merchant_trace=?,amount=?,status="created",error_message=NULL WHERE id=?')
            ->execute([$reference, $merchantTrace, $amount, $attemptId]);
    }

    try {
        $response = paynow_validate_init_response(paynow_parse(paynow_post('https://www.paynow.co.zw/interface/initiatetransaction', $fields)));
        $browserUrl = trim((string)($response['browserurl'] ?? ''));
        $pollUrl = trim((string)($response['pollurl'] ?? ''));
        if (!paynow_url_allowed($browserUrl) || !paynow_url_allowed($pollUrl)) throw new RuntimeException('Paynow returned an invalid checkout address.');
        db()->prepare('UPDATE online_payment_attempts SET browser_url=?,poll_url=?,provider_reference=?,status="initiated",raw_response=?,updated_at=NOW() WHERE id=?')
            ->execute([$browserUrl, $pollUrl, ($response['paynowreference'] ?? null) ?: null, http_build_query($response), $attemptId]);
        return ['payment_url'=>$browserUrl, 'reference'=>$reference, 'status'=>'initiated'];
    } catch (Throwable $error) {
        db()->prepare('UPDATE online_payment_attempts SET status="failed",error_message=?,updated_at=NOW() WHERE id=?')->execute([substr($error->getMessage(), 0, 500), $attemptId]);
        throw $error;
    }
}

/**
 * Create (or reuse) a hosted Paynow checkout for the outstanding balance of
 * an issued USD invoice. The customer is sent to Paynow; the integration key
 * never leaves the server.
 */
function paynow_checkout_for_invoice(int $invoiceId): array {
    if (!paynow_enabled()) throw new RuntimeException('Paynow checkout is not enabled yet.');

    $query = db()->prepare('SELECT d.id,d.number,d.type,d.status,d.total,d.currency_code,c.name,c.email,c.phone,c.whatsapp FROM documents d JOIN customers c ON c.id=d.customer_id WHERE d.id=? LIMIT 1');
    $query->execute([$invoiceId]);
    $invoice = $query->fetch();
    if (!$invoice || $invoice['type'] !== 'invoice') throw new RuntimeException('Invoice not found.');
    if (in_array($invoice['status'], ['draft','cancelled'], true)) throw new RuntimeException('Issue the invoice before creating a Paynow payment link.');
    if (strtoupper((string)$invoice['currency_code']) !== 'USD') throw new RuntimeException('Paynow links are currently available for USD invoices only.');

    $financials = invoice_financials($invoiceId);
    $amount = round((float)$financials['balance'], 2);
    if ($amount <= 0) throw new RuntimeException('This invoice has no outstanding balance.');

    $existing = db()->prepare('SELECT * FROM online_payment_attempts WHERE provider="paynow" AND invoice_id=? AND web_order_id IS NULL ORDER BY id DESC LIMIT 1');
    $existing->execute([$invoiceId]);
    $attempt = $existing->fetch();
    if ($attempt && abs((float)$attempt['amount'] - $amount) <= 0.009 && !empty($attempt['browser_url']) && in_array($attempt['status'], ['created','initiated','sent'], true) && paynow_url_allowed((string)$attempt['browser_url'])) {
        return ['payment_url'=>$attempt['browser_url'], 'reference'=>$attempt['merchant_reference'], 'status'=>$attempt['status']];
    }

    // A unique reference is required when a partial payment changes the balance.
    $reference = substr('PAY-' . preg_replace('/[^A-Za-z0-9-]/', '-', (string)$invoice['number']) . '-' . strtoupper(bin2hex(random_bytes(3))), 0, 80);
    $merchantTrace = strtoupper(substr(hash('sha256', $reference), 0, 32));
    $returnUrl = url('api/paynow-return.php?reference=' . rawurlencode($reference));
    $resultUrl = url('api/paynow-result.php');
    $phone = trim((string)($invoice['whatsapp'] ?: $invoice['phone']));
    $fields = paynow_initiation_fields(
        $reference,
        $amount,
        'Soda ZW invoice ' . $invoice['number'],
        $returnUrl,
        $resultUrl,
        (string)($invoice['email'] ?? ''),
        $phone,
        (string)($invoice['name'] ?? ''),
        $merchantTrace
    );

    db()->prepare('INSERT INTO online_payment_attempts (provider,web_order_id,invoice_id,merchant_reference,merchant_trace,amount,currency_code,status) VALUES ("paynow",NULL,?,?,?,?,"USD","created")')
        ->execute([$invoiceId, $reference, $merchantTrace, $amount]);
    $attemptId = (int)db()->lastInsertId();

    try {
        $response = paynow_validate_init_response(paynow_parse(paynow_post('https://www.paynow.co.zw/interface/initiatetransaction', $fields)));
        $browserUrl = trim((string)($response['browserurl'] ?? ''));
        $pollUrl = trim((string)($response['pollurl'] ?? ''));
        if (!paynow_url_allowed($browserUrl) || !paynow_url_allowed($pollUrl)) throw new RuntimeException('Paynow returned an invalid checkout address.');
        db()->prepare('UPDATE online_payment_attempts SET browser_url=?,poll_url=?,provider_reference=?,status="initiated",raw_response=?,updated_at=NOW() WHERE id=?')
            ->execute([$browserUrl, $pollUrl, ($response['paynowreference'] ?? null) ?: null, http_build_query($response), $attemptId]);
        return ['payment_url'=>$browserUrl, 'reference'=>$reference, 'status'=>'initiated'];
    } catch (Throwable $error) {
        db()->prepare('UPDATE online_payment_attempts SET status="failed",error_message=?,updated_at=NOW() WHERE id=?')->execute([substr($error->getMessage(), 0, 500), $attemptId]);
        throw $error;
    }
}

function paynow_paid_status(string $status): bool {
    return in_array(strtolower(trim($status)), ['paid','awaiting delivery','delivered'], true);
}

function paynow_apply_status(array $values): array {
    if (!paynow_verify($values)) throw new RuntimeException('Paynow status verification failed.');
    $reference = trim((string)($values['reference'] ?? ''));
    $amount = round((float)($values['amount'] ?? 0), 2);
    $currency = strtoupper(trim((string)($values['currency'] ?? $values['currencycode'] ?? 'USD')));
    if ($currency !== 'USD') throw new RuntimeException('The Paynow currency does not match the Soda ZW invoice currency.');
    $providerStatus = trim((string)($values['status'] ?? ''));
    $status = paynow_paid_status($providerStatus) ? 'paid' : match (strtolower($providerStatus)) {
        'cancelled' => 'cancelled',
        'refunded' => 'refunded',
        'disputed' => 'disputed',
        'sent' => 'sent',
        default => 'created',
    };
    db()->beginTransaction();
    try {
        // Lock the attempt so simultaneous callback and manual polling cannot
        // create duplicate customer payments.
        $find = db()->prepare('SELECT * FROM online_payment_attempts WHERE provider="paynow" AND merchant_reference=? LIMIT 1 FOR UPDATE');
        $find->execute([$reference]);
        $attempt = $find->fetch();
        if (!$attempt) throw new RuntimeException('The Paynow payment reference is unknown.');
        if (abs($amount - (float)$attempt['amount']) > 0.009) throw new RuntimeException('The Paynow amount does not match the Soda ZW amount due.');
        $paymentId = (int)($attempt['payment_id'] ?? 0);
        if ($status === 'paid' && !$paymentId && !empty($attempt['invoice_id'])) {
            $account = db()->query('SELECT id FROM payment_accounts WHERE name="Paynow USD" AND active=1 LIMIT 1')->fetchColumn();
            if (!$account) {
                db()->prepare('INSERT INTO payment_accounts (name,type,currency_code,details,active) VALUES ("Paynow USD","online","USD","Online Paynow settlement",1)')->execute();
                $account = db()->lastInsertId();
            }
            $owner = db()->query("SELECT id FROM users WHERE active=1 ORDER BY FIELD(role,'owner','admin','staff'),id LIMIT 1")->fetchColumn();
            if (!$owner) throw new RuntimeException('No active billing user is available to record the payment.');
            $providerReference = trim((string)($values['paynowreference'] ?? $attempt['provider_reference'] ?? ''));
            $channel = trim((string)($values['paymentchannel'] ?? 'Paynow')) ?: 'Paynow';
            db()->prepare('INSERT INTO payments (invoice_id,account_id,payment_date,amount,received_amount,received_currency,exchange_rate,fee_amount,method,reference,notes,created_by) VALUES (?,?,CURDATE(),?,?,"USD",1,0,?,?,?,?)')
                ->execute([(int)$attempt['invoice_id'], (int)$account, $amount, $amount, 'Paynow / '.$channel, $providerReference ?: $reference, 'Verified automatically through Paynow checkout', (int)$owner]);
            $paymentId = (int)db()->lastInsertId();
            update_invoice_status((int)$attempt['invoice_id']);
            if (!empty($attempt['web_order_id'])) {
                db()->prepare('UPDATE web_orders SET status="confirmed" WHERE id=?')->execute([(int)$attempt['web_order_id']]);
                db()->prepare('UPDATE crm_opportunities SET stage="order_confirmed",next_follow_up_at=DATE_ADD(NOW(),INTERVAL 1 DAY) WHERE web_order_id=?')->execute([(int)$attempt['web_order_id']]);
            }
            $invoiceStatusStmt = db()->prepare('SELECT status FROM documents WHERE id=?');
            $invoiceStatusStmt->execute([(int)$attempt['invoice_id']]);
            $invoiceStatus = (string)$invoiceStatusStmt->fetchColumn();
            db()->prepare('INSERT INTO document_events (document_id,event_type,from_status,to_status,notes,created_by) VALUES (? ,"payment_received",NULL,?,?,?)')
                ->execute([(int)$attempt['invoice_id'], $invoiceStatus, 'Paynow payment verified: '.($providerReference ?: $reference), (int)$owner]);
        }
        db()->prepare('UPDATE online_payment_attempts SET payment_id=?,provider_reference=?,poll_url=COALESCE(NULLIF(?,""),poll_url),payment_channel=?,status=?,raw_response=?,error_message=NULL,paid_at=IF(?="paid",COALESCE(paid_at,NOW()),paid_at),updated_at=NOW() WHERE id=?')
            ->execute([$paymentId ?: null, ($values['paynowreference'] ?? null) ?: $attempt['provider_reference'], (string)($values['pollurl'] ?? ''), ($values['paymentchannel'] ?? null) ?: null, $status, http_build_query($values), $status, (int)$attempt['id']]);
        db()->commit();
    } catch (Throwable $error) {
        if (db()->inTransaction()) db()->rollBack();
        throw $error;
    }
    return ['reference'=>$reference, 'status'=>$status, 'provider_status'=>$providerStatus, 'paid'=>$status === 'paid'];
}
