<?php
/**
 * Safaricom Daraja API — STK Push (Lipa Na M-Pesa Online)
 * Credentials are loaded from payment_settings (admin panel).
 */
class MpesaService
{
    private array $cfg;
    private string $baseUrl;

    public function __construct(array $settings)
    {
        $this->cfg = $settings;
        $env = ($settings['mpesa_env'] ?? 'sandbox') === 'live' ? 'live' : 'sandbox';
        $this->baseUrl = $env === 'live'
            ? 'https://api.safaricom.co.ke'
            : 'https://sandbox.safaricom.co.ke';
    }

    public function isConfigured(): bool
    {
        return !empty($this->cfg['mpesa_enabled'])
            && !empty($this->cfg['mpesa_consumer_key'])
            && !empty($this->cfg['mpesa_consumer_secret'])
            && !empty($this->cfg['mpesa_shortcode'])
            && !empty($this->cfg['mpesa_passkey']);
    }

    /** Normalize Kenyan MSISDN to 2547XXXXXXXX */
    public static function normalizePhone(string $phone): ?string
    {
        $p = preg_replace('/\D+/', '', $phone);
        if ($p === null || $p === '') {
            return null;
        }
        if (str_starts_with($p, '0') && strlen($p) === 10) {
            $p = '254' . substr($p, 1);
        } elseif (str_starts_with($p, '7') && strlen($p) === 9) {
            $p = '254' . $p;
        } elseif (str_starts_with($p, '+254')) {
            $p = substr($p, 1);
        }
        if (!preg_match('/^2547\d{8}$/', $p) && !preg_match('/^2541\d{8}$/', $p)) {
            return null;
        }
        return $p;
    }

    public function accessToken(): ?string
    {
        $key = $this->cfg['mpesa_consumer_key'] ?? '';
        $secret = $this->cfg['mpesa_consumer_secret'] ?? '';
        if ($key === '' || $secret === '') {
            return null;
        }
        $url = $this->baseUrl . '/oauth/v1/generate?grant_type=client_credentials';
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER => ['Authorization: Basic ' . base64_encode($key . ':' . $secret)],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
        ]);
        $raw = curl_exec($ch);
        $err = curl_error($ch);
        curl_close($ch);
        if ($raw === false) {
            return null;
        }
        $data = json_decode($raw, true);
        return is_array($data) ? ($data['access_token'] ?? null) : null;
    }

    /**
     * Initiate STK Push.
     * @return array{ok:bool,message:string,checkout_request_id?:string,merchant_request_id?:string,raw?:array}
     */
    public function stkPush(string $phone254, float $amount, string $accountRef, string $description, int $maxAttempts = 3): array
    {
        if (!$this->isConfigured()) {
            return ['ok' => false, 'message' => 'M-Pesa is not configured. Ask admin to set Daraja credentials.'];
        }
        $last = ['ok' => false, 'message' => 'STK Push failed after retries.'];
        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            $result = $this->stkPushOnce($phone254, $amount, $accountRef, $description);
            if ($result['ok']) {
                $result['attempts'] = $attempt;
                return $result;
            }
            $last = $result;
            $last['attempts'] = $attempt;
            // Retry only on network / token / 5xx style failures
            $msg = strtolower($result['message'] ?? '');
            $retryable = str_contains($msg, 'network') || str_contains($msg, 'token') || str_contains($msg, 'timeout') || str_contains($msg, '503') || str_contains($msg, '500');
            if (!$retryable || $attempt >= $maxAttempts) {
                break;
            }
            usleep(400000 * $attempt); // 0.4s, 0.8s, ...
        }
        $last['message'] = ($last['message'] ?? 'STK failed') . ' (after ' . ($last['attempts'] ?? $maxAttempts) . ' attempts)';
        return $last;
    }

    private function stkPushOnce(string $phone254, float $amount, string $accountRef, string $description): array
    {
        $token = $this->accessToken();
        if (!$token) {
            return ['ok' => false, 'message' => 'Could not get M-Pesa access token. Check consumer key/secret and environment.'];
        }

        $shortcode = (string) $this->cfg['mpesa_shortcode'];
        $passkey = (string) $this->cfg['mpesa_passkey'];
        $timestamp = date('YmdHis');
        $password = base64_encode($shortcode . $passkey . $timestamp);
        $callback = (string) ($this->cfg['mpesa_callback_url'] ?? '');
        if ($callback === '') {
            global $config;
            $callback = rtrim($config['app_url'] ?? '', '/') . '/premium/mpesa/callback';
        }

        $payload = [
            'BusinessShortCode' => $shortcode,
            'Password' => $password,
            'Timestamp' => $timestamp,
            'TransactionType' => 'CustomerPayBillOnline',
            'Amount' => (int) max(1, round($amount)),
            'PartyA' => $phone254,
            'PartyB' => $shortcode,
            'PhoneNumber' => $phone254,
            'CallBackURL' => $callback,
            'AccountReference' => substr($accountRef, 0, 12),
            'TransactionDesc' => substr($description, 0, 13),
        ];

        $url = $this->baseUrl . '/mpesa/stkpush/v1/processrequest';
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Content-Type: application/json',
            ],
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 45,
        ]);
        $raw = curl_exec($ch);
        $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err = curl_error($ch);
        curl_close($ch);

        if ($raw === false) {
            return ['ok' => false, 'message' => 'Network error contacting M-Pesa: ' . $err];
        }
        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['ok' => false, 'message' => 'Invalid response from M-Pesa.', 'raw' => ['body' => $raw, 'http' => $http]];
        }

        $code = (string) ($data['ResponseCode'] ?? $data['errorCode'] ?? '');
        if ($code === '0') {
            return [
                'ok' => true,
                'message' => $data['CustomerMessage'] ?? 'Check your phone for the M-Pesa prompt.',
                'checkout_request_id' => $data['CheckoutRequestID'] ?? '',
                'merchant_request_id' => $data['MerchantRequestID'] ?? '',
                'raw' => $data,
            ];
        }

        return [
            'ok' => false,
            'message' => $data['errorMessage'] ?? $data['ResponseDescription'] ?? 'STK Push failed.',
            'raw' => $data,
        ];
    }

    /**
     * Parse Daraja callback body (ResultCode 0 = success).
     * @return array{success:bool,checkout_request_id:?string,mpesa_receipt:?string,amount:?float,phone:?string,result_desc:string}
     */
    public static function parseCallback(array $body): array
    {
        $stk = $body['Body']['stkCallback'] ?? $body['stkCallback'] ?? null;
        if (!is_array($stk)) {
            return [
                'success' => false,
                'checkout_request_id' => null,
                'mpesa_receipt' => null,
                'amount' => null,
                'phone' => null,
                'result_desc' => 'Malformed callback',
            ];
        }
        $resultCode = (int) ($stk['ResultCode'] ?? -1);
        $checkout = $stk['CheckoutRequestID'] ?? null;
        $desc = (string) ($stk['ResultDesc'] ?? '');
        $receipt = null;
        $amount = null;
        $phone = null;
        if ($resultCode === 0 && !empty($stk['CallbackMetadata']['Item'])) {
            foreach ($stk['CallbackMetadata']['Item'] as $item) {
                $name = $item['Name'] ?? '';
                $val = $item['Value'] ?? null;
                if ($name === 'MpesaReceiptNumber') {
                    $receipt = (string) $val;
                } elseif ($name === 'Amount') {
                    $amount = (float) $val;
                } elseif ($name === 'PhoneNumber') {
                    $phone = (string) $val;
                }
            }
        }
        return [
            'success' => $resultCode === 0,
            'checkout_request_id' => $checkout,
            'mpesa_receipt' => $receipt,
            'amount' => $amount,
            'phone' => $phone,
            'result_desc' => $desc,
        ];
    }


    /**
     * Query STK Push status (optional reconciliation).
     * @return array{ok:bool,data?:array,message?:string}
     */
    public function queryStkStatus(string $checkoutRequestId): array
    {
        if (!$this->isConfigured()) {
            return ['ok' => false, 'message' => 'M-Pesa not configured'];
        }
        $token = $this->accessToken();
        if (!$token) {
            return ['ok' => false, 'message' => 'Could not get access token'];
        }
        $shortcode = (string) $this->cfg['mpesa_shortcode'];
        $passkey = (string) $this->cfg['mpesa_passkey'];
        $timestamp = date('YmdHis');
        $password = base64_encode($shortcode . $passkey . $timestamp);
        $payload = [
            'BusinessShortCode' => $shortcode,
            'Password' => $password,
            'Timestamp' => $timestamp,
            'CheckoutRequestID' => $checkoutRequestId,
        ];
        $url = $this->baseUrl . '/mpesa/stkpushquery/v1/query';
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Content-Type: application/json',
            ],
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
        ]);
        $raw = curl_exec($ch);
        $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($raw === false) {
            return ['ok' => false, 'message' => 'Network error on STK query'];
        }
        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return ['ok' => false, 'message' => 'Invalid query response', 'http' => $http];
        }
        return ['ok' => true, 'data' => $data, 'http' => $http];
    }

    /**
     * Best-effort check that callback IP belongs to known Safaricom ranges.
     * Daraja does not sign callbacks; combine with pending CheckoutRequestID matching.
     */
    public static function isLikelySafaricomIp(string $ip): bool
    {
        // Allow local / private for staging tunnels only when explicitly enabled
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
            return false;
        }
        // Documented / commonly observed Safaricom API egress (may change; keep allowlist configurable)
        $cidrs = [
            '196.201.214.0/24',
            '196.201.215.0/24',
            '196.201.216.0/24',
            '196.201.217.0/24',
            '196.201.218.0/24',
            '196.201.219.0/24',
            '41.223.0.0/16',
            '105.30.0.0/15',
        ];
        $ipLong = ip2long($ip);
        if ($ipLong === false) {
            return false;
        }
        foreach ($cidrs as $cidr) {
            [$subnet, $mask] = explode('/', $cidr);
            $mask = (int) $mask;
            $subnetLong = ip2long($subnet);
            if ($subnetLong === false) {
                continue;
            }
            $maskLong = -1 << (32 - $mask);
            if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) {
                return true;
            }
        }
        return false;
    }

}
