<?php
// ============================================================
//  Buy Local Lowveld — Zoho Books integration
// ============================================================
//
//  Auth: OAuth2 Self Client with permanent refresh token.
//  Access tokens last 1 hour and are cached in zoho_tokens.
//
//  Public API:
//    zoho_log($member_id, $method, $endpoint, $status, $ok, $req, $resp, $note)
//    zoho_access_token()                          → string|null
//    zoho_request($method, $path, $body=null)     → ['ok'=>bool, 'status'=>int, 'data'=>mixed, 'raw'=>string]
//    zoho_create_contact($member)                 → string|null contact_id
//    zoho_get_contact($contact_id)                → array|null
//    zoho_sync_member($member_id)                 → bool — creates contact if missing
//
// ============================================================

require_once __DIR__ . '/db.php';
require_once __DIR__ . '/config.php';

/** Are credentials configured? */
function zoho_is_configured(): bool {
    return defined('ZOHO_CLIENT_ID')     && ZOHO_CLIENT_ID     !== ''
        && defined('ZOHO_CLIENT_SECRET') && ZOHO_CLIENT_SECRET !== ''
        && defined('ZOHO_REFRESH_TOKEN') && ZOHO_REFRESH_TOKEN !== ''
        && defined('ZOHO_ORG_ID')        && ZOHO_ORG_ID        !== '';
}

/** Determine accounts host from configured DC */
function zoho_accounts_host(): string {
    $dc = strtolower(defined('ZOHO_DC') ? ZOHO_DC : 'com');
    return 'https://accounts.zoho.' . $dc;
}

/** Determine API host (after first auth, we cache the API domain Zoho returns) */
function zoho_api_host(): string {
    // Zoho returns api_domain in token response — use cached one if available.
    $row = db_row("SELECT api_domain FROM zoho_tokens ORDER BY created_at DESC LIMIT 1");
    if ($row && !empty($row['api_domain'])) return rtrim($row['api_domain'], '/');
    $dc = strtolower(defined('ZOHO_DC') ? ZOHO_DC : 'com');
    return 'https://www.zohoapis.' . $dc;
}

/**
 * Append an entry to zoho_log.
 * Safe to call even if table doesn't exist yet (silently fails).
 */
function zoho_log(?int $member_id, string $method, string $endpoint, int $status, bool $success, $request = null, $response = null, ?string $note = null): void {
    try {
        db_exec(
            "INSERT INTO zoho_log
                (member_id, method, endpoint, http_status, success, request, response, note)
             VALUES (:m, :mt, :ep, :st, :ok, :rq, :rs, :nt)",
            [
                'm'  => $member_id,
                'mt' => substr($method, 0, 10),
                'ep' => substr($endpoint, 0, 255),
                'st' => $status,
                'ok' => $success ? 1 : 0,
                'rq' => is_string($request) ? substr($request, 0, 4000) : (is_array($request) ? substr(json_encode($request), 0, 4000) : null),
                'rs' => is_string($response) ? substr($response, 0, 8000) : (is_array($response) ? substr(json_encode($response), 0, 8000) : null),
                'nt' => $note ? substr($note, 0, 255) : null,
            ]
        );
    } catch (Throwable $e) {
        error_log('zoho_log failed: ' . $e->getMessage());
    }
}

/**
 * Return a valid access token, refreshing if needed.
 * Uses a small DB cache so we don't refresh on every call.
 */
function zoho_access_token(): ?string {
    if (!zoho_is_configured()) return null;

    // 1) Check cache (with 60s safety margin)
    $cached = db_row(
        "SELECT access_token, api_domain, expires_at
           FROM zoho_tokens
           WHERE expires_at > DATE_ADD(NOW(), INTERVAL 60 SECOND)
           ORDER BY created_at DESC LIMIT 1"
    );
    if ($cached) return $cached['access_token'];

    // 2) Refresh
    $url  = zoho_accounts_host() . '/oauth/v2/token';
    $body = http_build_query([
        'refresh_token' => ZOHO_REFRESH_TOKEN,
        'client_id'     => ZOHO_CLIENT_ID,
        'client_secret' => ZOHO_CLIENT_SECRET,
        'grant_type'    => 'refresh_token',
    ]);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded'],
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_SSL_VERIFYPEER => true,
    ]);
    $response  = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($response === false) {
        zoho_log(null, 'POST', '/oauth/v2/token', 0, false, '[hidden]', 'curl failed', 'token refresh');
        return null;
    }

    $data = json_decode($response, true);
    if (!is_array($data) || empty($data['access_token'])) {
        zoho_log(null, 'POST', '/oauth/v2/token', $http_code, false, '[hidden]', $response, 'token refresh failed');
        return null;
    }

    $expires_in = (int)($data['expires_in'] ?? 3600);
    db_exec(
        "INSERT INTO zoho_tokens (access_token, api_domain, expires_at)
         VALUES (:t, :d, DATE_ADD(NOW(), INTERVAL :s SECOND))",
        [
            't' => $data['access_token'],
            'd' => $data['api_domain'] ?? zoho_api_host(),
            's' => $expires_in,
        ]
    );
    // Trim old entries (keep last 5)
    db_exec("DELETE FROM zoho_tokens WHERE id NOT IN (SELECT id FROM (SELECT id FROM zoho_tokens ORDER BY created_at DESC LIMIT 5) t)");
    zoho_log(null, 'POST', '/oauth/v2/token', $http_code, true, '[hidden]', '[redacted]', 'token refreshed');
    return $data['access_token'];
}

/**
 * Generic Zoho Books API request.
 * Pass $body as array (will be JSON-encoded) for POST/PUT.
 * GET ?organization_id=... is added automatically.
 */
function zoho_request(string $method, string $path, $body = null, ?int $member_id = null): array {
    $token = zoho_access_token();
    if (!$token) {
        return ['ok' => false, 'status' => 0, 'data' => null, 'raw' => '', 'error' => 'No access token'];
    }

    // Build URL — always include organization_id
    $base = zoho_api_host() . '/books/v3';
    $sep  = (strpos($path, '?') === false) ? '?' : '&';
    $url  = $base . $path . $sep . 'organization_id=' . ZOHO_ORG_ID;

    $headers = [
        'Authorization: Zoho-oauthtoken ' . $token,
    ];

    $payload = null;
    if ($body !== null) {
        $payload   = is_string($body) ? $body : json_encode($body);
        $headers[] = 'Content-Type: application/json;charset=UTF-8';
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_SSL_VERIFYPEER => true,
    ]);
    if ($payload !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
    }

    $response  = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curl_err  = curl_error($ch);
    curl_close($ch);

    if ($response === false) {
        zoho_log($member_id, $method, $path, 0, false, $payload, $curl_err, 'connection failed');
        return ['ok' => false, 'status' => 0, 'data' => null, 'raw' => '', 'error' => $curl_err];
    }

    $data    = json_decode($response, true);
    // Zoho convention: top-level "code" == 0 means success
    $success = ($http_code >= 200 && $http_code < 300)
            && is_array($data)
            && (isset($data['code']) ? (int)$data['code'] === 0 : true);

    zoho_log(
        $member_id,
        $method,
        $path,
        $http_code,
        $success,
        $payload,
        $response
    );

    return [
        'ok'     => $success,
        'status' => $http_code,
        'data'   => $data,
        'raw'    => $response,
    ];
}

/**
 * Create a contact in Zoho Books from a member row.
 * Returns the new contact_id (string) or null on failure.
 */
function zoho_create_contact(array $member): ?string {
    $contact_name = trim(($member['business_name'] ?? '') ?: (($member['first_name'] ?? '') . ' ' . ($member['last_name'] ?? '')));
    if ($contact_name === '') $contact_name = 'Member ' . ($member['id'] ?? '');

    // ── Validate email BEFORE calling Zoho ──────────────────
    // Zoho rejects with "Invalid value passed for Email Address" on bad emails,
    // and we don't want to fire a doomed API call. We also strip whitespace
    // and lowercase to dodge common formatting issues that Zoho is strict about.
    $email = strtolower(trim((string)($member['email'] ?? '')));
    if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
        zoho_log(
            (int)($member['id'] ?? 0), 'POST', '/contacts', 0, false,
            ['email' => $member['email'] ?? null],
            null,
            'Pre-flight check failed: email "' . ($member['email'] ?? '(empty)') . '" is not valid'
        );
        app_log('zoho_create_contact aborted: invalid email for member #' . ($member['id'] ?? '?') . ' (' . ($member['email'] ?? '') . ')');
        return null;
    }

    $person = [
        'salutation' => '',
        'first_name' => trim((string)($member['first_name'] ?? '')),
        'last_name'  => trim((string)($member['last_name']  ?? '')),
        'email'      => $email,
        'phone'      => trim((string)($member['phone']      ?? '')),
        'is_primary_contact' => true,
    ];

    $payload = [
        'contact_name'    => $contact_name,
        'company_name'    => $member['business_name'] ?? null,
        'contact_type'    => 'customer',
        'contact_persons' => [$person],
        'billing_address' => [
            'address' => $member['address'] ?? '',
            'country' => 'South Africa',
        ],
    ];

    // Custom fields aren't strict — Zoho ignores unknown keys
    if (!empty($member['tier'])) {
        $payload['notes'] = 'Buy Local tier: ' . $member['tier'];
    }

    $result = zoho_request('POST', '/contacts', $payload, (int)($member['id'] ?? 0));
    if (!$result['ok'] || empty($result['data']['contact']['contact_id'])) {
        return null;
    }
    return (string)$result['data']['contact']['contact_id'];
}

/** Read a contact back from Zoho. Returns the contact array or null. */
function zoho_get_contact(string $contact_id, ?int $member_id = null): ?array {
    $result = zoho_request('GET', '/contacts/' . urlencode($contact_id), null, $member_id);
    if (!$result['ok'] || empty($result['data']['contact'])) return null;
    return $result['data']['contact'];
}

/**
 * Find a Zoho customer by email address.
 * Returns the contact array or null if not found.
 * Used during signup to detect "this email is already a Zoho customer" cases.
 */
function zoho_find_contact_by_email(string $email): ?array {
    $email = strtolower(trim($email));
    if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) return null;
    if (!zoho_is_configured()) return null;

    // Zoho's /contacts endpoint accepts an email filter
    $result = zoho_request('GET', '/contacts?email=' . urlencode($email));
    if (!$result['ok']) return null;

    $contacts = $result['data']['contacts'] ?? [];
    if (empty($contacts)) return null;

    // Return the first match (Zoho deduplicates by email by default)
    return $contacts[0];
}

/**
 * Ensure the given member has a Zoho contact. Creates one if missing.
 * Returns true on success, false on any failure.
 */
function zoho_sync_member(int $member_id): bool {
    if (!zoho_is_configured()) return false;

    $member = db_row('SELECT * FROM members WHERE id = :id', ['id' => $member_id]);
    if (!$member) return false;

    // Already synced? Verify the ID still works
    if (!empty($member['zoho_contact_id'])) {
        $existing = zoho_get_contact($member['zoho_contact_id'], $member_id);
        if ($existing) return true;
        // Stale ID — fall through to look up / create
    }

    // First check: does Zoho already have a customer with this email?
    // This avoids creating duplicates when a member existed in Zoho before
    // the website signup (e.g. legacy customers, manual creations).
    $existing_by_email = zoho_find_contact_by_email((string)$member['email']);
    if ($existing_by_email && !empty($existing_by_email['contact_id'])) {
        $contact_id = (string)$existing_by_email['contact_id'];
        db_exec(
            'UPDATE members SET zoho_contact_id = :c WHERE id = :id',
            ['c' => $contact_id, 'id' => $member_id]
        );
        app_log("Linked member #$member_id to existing Zoho contact $contact_id (matched by email)");
        return true;
    }

    // Truly new customer — create in Zoho
    $contact_id = zoho_create_contact($member);
    if (!$contact_id) return false;

    db_exec(
        'UPDATE members SET zoho_contact_id = :c WHERE id = :id',
        ['c' => $contact_id, 'id' => $member_id]
    );
    return true;
}

// ============================================================
//  Phase 2g — Full payment automation
// ============================================================
//
//  zoho_record_payment_full() does the entire Zoho flow when
//  a payment comes in via PayFast / Netcash:
//    1. Ensure Zoho contact exists (sync if missing)
//    2. Create invoice
//    3. Mark invoice as sent
//    4. Record customer payment to gateway bank account
//    5. Record gateway fee as expense from the same account
//    6. Optionally email invoice to customer (Zoho-side)
//    7. Save Zoho IDs back to local invoices row
//
//  Idempotent — if local_invoice.zoho_invoice_id is already set,
//  this returns success without doing anything.
//
// ============================================================

require_once __DIR__ . '/settings.php';

/**
 * Run the full Zoho automation for a paid invoice.
 *
 * @param int    $local_invoice_id  ID in our `invoices` table
 * @param string $gateway           'payfast' or 'netcash'
 * @return array ['ok'=>bool, 'invoice_id'=>?string, 'payment_id'=>?string, 'fee_id'=>?string, 'error'=>?string]
 */
function zoho_record_payment_full(int $local_invoice_id, string $gateway = 'payfast'): array {
    if (!zoho_is_configured()) {
        return ['ok'=>false, 'error'=>'Zoho not configured'];
    }

    // Load the local invoice
    $inv = db_row('SELECT * FROM invoices WHERE id = :id', ['id' => $local_invoice_id]);
    if (!$inv) return ['ok'=>false, 'error'=>'Local invoice not found'];

    // Idempotency: already synced?
    if (!empty($inv['zoho_invoice_id'])) {
        return [
            'ok' => true,
            'invoice_id' => $inv['zoho_invoice_id'],
            'payment_id' => $inv['zoho_payment_id'],
            'fee_id'     => $inv['zoho_fee_expense_id'],
            'note'       => 'already synced (no-op)',
        ];
    }

    $member = db_row('SELECT * FROM members WHERE id = :id', ['id' => $inv['member_id']]);
    if (!$member) return ['ok'=>false, 'error'=>'Member not found for this invoice'];

    // ── Pre-flight: validate member data Zoho is strict about ─
    $member_email = strtolower(trim((string)($member['email'] ?? '')));
    if ($member_email === '' || !filter_var($member_email, FILTER_VALIDATE_EMAIL)) {
        $err = 'Member email "' . ($member['email'] ?? '(empty)') . '" is not valid for Zoho';
        zoho_record_set_error($local_invoice_id, $err);
        app_log("zoho_record_payment_full: $err for member #{$member['id']}");
        return ['ok'=>false, 'error'=>$err];
    }
    $member['email'] = $member_email;

    // ── 1. Ensure Zoho contact ────────────────────────────────
    if (empty($member['zoho_contact_id'])) {
        $synced = zoho_sync_member((int)$member['id']);
        if (!$synced) {
            zoho_record_set_error($local_invoice_id, 'Could not create Zoho contact');
            return ['ok'=>false, 'error'=>'Could not create Zoho contact'];
        }
        $member = db_row('SELECT * FROM members WHERE id = :id', ['id' => $member['id']]);
    }
    $contact_id = $member['zoho_contact_id'];

    // Bank account for this gateway
    $bank_account_id = (string)setting_get('zoho.' . $gateway . '_account_id', '');
    if ($bank_account_id === '') {
        zoho_record_set_error($local_invoice_id, "No Zoho bank account configured for $gateway. Set in Settings → Payment Fees.");
        return ['ok'=>false, 'error'=>"No Zoho bank account configured for $gateway"];
    }

    // Amount in Rand (Zoho uses decimal, our DB uses cents)
    $amount = round(((int)$inv['amount_cents']) / 100, 2);
    if ($amount <= 0) {
        return ['ok'=>false, 'error'=>'Invoice amount is zero or negative'];
    }

    $invoice_date = $inv['issued_at']
        ? date('Y-m-d', strtotime($inv['issued_at']))
        : date('Y-m-d');
    $line_name = $member['tier'] . ' Membership';
    $line_desc = 'Buy Local Lowveld monthly membership for ' . date('F Y', strtotime($invoice_date));

    // ── 2. Create the invoice ─────────────────────────────────
    $payload = [
        'customer_id'  => $contact_id,
        'date'         => $invoice_date,
        'line_items'   => [[
            'name'        => $line_name,
            'description' => $line_desc,
            'rate'        => $amount,
            'quantity'    => 1,
        ]],
        // No reference_number — Zoho assigns its own invoice_number and we
        // adopt that as the customer-facing reference everywhere.
        'notes' => 'Auto-created from Buy Local Lowveld website. Local invoice #' . $local_invoice_id,
    ];

    $r = zoho_request('POST', '/invoices', $payload, (int)$member['id']);
    if (!$r['ok']) {
        zoho_record_set_error($local_invoice_id, 'Create invoice failed: ' . substr($r['raw'] ?? '', 0, 300));
        return ['ok'=>false, 'error'=>'Create invoice failed: HTTP '.$r['status']];
    }
    $zoho_invoice_id     = (string)($r['data']['invoice']['invoice_id']     ?? '');
    $zoho_invoice_number = (string)($r['data']['invoice']['invoice_number'] ?? '');
    if (!$zoho_invoice_id) {
        zoho_record_set_error($local_invoice_id, 'Zoho returned no invoice_id');
        return ['ok'=>false, 'error'=>'Zoho returned no invoice_id'];
    }

    // Promote Zoho's invoice number to be the customer-facing number on our side too,
    // so member portal / emails / statements all show the same reference Zoho uses.
    if ($zoho_invoice_number !== '') {
        db_exec(
            'UPDATE invoices SET number = :n, zoho_invoice_number = :zn WHERE id = :id',
            ['n' => $zoho_invoice_number, 'zn' => $zoho_invoice_number, 'id' => $local_invoice_id]
        );
        // Refresh the in-memory copy so subsequent steps reference the new number
        $inv['number']              = $zoho_invoice_number;
        $inv['zoho_invoice_number'] = $zoho_invoice_number;
    }

    // ── 3. Mark invoice as sent ───────────────────────────────
    $r = zoho_request('POST', '/invoices/' . urlencode($zoho_invoice_id) . '/status/sent', [], (int)$member['id']);
    if (!$r['ok']) {
        zoho_record_set_error($local_invoice_id, 'Mark sent failed: HTTP '.$r['status']);
        return ['ok'=>false, 'invoice_id'=>$zoho_invoice_id, 'invoice_number'=>$zoho_invoice_number, 'error'=>'Mark sent failed'];
    }

    // ── 4. Record customer payment to gateway bank account ───
    $payment_payload = [
        'customer_id'    => $contact_id,
        'payment_mode'   => 'onlinepayment',
        'amount'         => $amount,
        'date'           => $invoice_date,
        'reference_number' => $inv['number'],
        'description'    => ucfirst($gateway) . ' payment for ' . $inv['number'],
        'account_id'     => $bank_account_id,
        'invoices'       => [[
            'invoice_id'     => $zoho_invoice_id,
            'amount_applied' => $amount,
        ]],
    ];
    $r = zoho_request('POST', '/customerpayments', $payment_payload, (int)$member['id']);
    $zoho_payment_id = '';
    if ($r['ok']) {
        $zoho_payment_id = (string)($r['data']['payment']['payment_id'] ?? '');
    } else {
        zoho_record_set_error($local_invoice_id, 'Record payment failed: HTTP '.$r['status']);
        // Still save the invoice ID + number so admin can retry payment manually
        db_exec('UPDATE invoices SET zoho_invoice_id=:zi, zoho_invoice_number=:zn WHERE id=:id',
                ['zi'=>$zoho_invoice_id, 'zn'=>$zoho_invoice_number, 'id'=>$local_invoice_id]);
        return ['ok'=>false, 'invoice_id'=>$zoho_invoice_id, 'error'=>'Record payment failed'];
    }

    // ── 5. Record gateway fee expense ─────────────────────────
    $zoho_fee_id = '';
    $fees = gateway_calc_fee($gateway, $amount);
    if ($fees['fee_incl'] > 0) {
        $fee_account_id = (string)setting_get('gateway_fee_account_id', '');
        if (!$fee_account_id) {
            // Auto-detect: look for "Bank Charges" / "Merchant Fees"
            $exp = zoho_request('GET', '/chartofaccounts?filter_by=AccountType.Expense&per_page=200');
            if ($exp['ok']) {
                foreach (($exp['data']['chartofaccounts'] ?? []) as $coa) {
                    $name = strtolower($coa['account_name'] ?? '');
                    if (str_contains($name, 'bank charge') || str_contains($name, 'merchant fee') || str_contains($name, 'gateway fee') || str_contains($name, 'bank fee')) {
                        $fee_account_id = (string)$coa['account_id'];
                        break;
                    }
                }
            }
        }

        if ($fee_account_id) {
            $exp_payload = [
                'account_id'              => $fee_account_id,
                'paid_through_account_id' => $bank_account_id,   // deduct from same gateway bank
                'date'                    => $invoice_date,
                'amount'                  => $fees['fee_incl'],
                'is_inclusive_tax'        => true,
                'description'             => ucfirst($gateway) . ' transaction fee on ' . $inv['number'],
                'reference_number'        => 'FEE-' . $inv['number'],
            ];
            $r = zoho_request('POST', '/expenses', $exp_payload, (int)$member['id']);
            if ($r['ok']) {
                $zoho_fee_id = (string)($r['data']['expense']['expense_id'] ?? '');
            }
        }
    }

    // ── 6. Optionally email invoice to customer ──────────────
    if ((bool)setting_get('zoho.auto_email_invoice', false)) {
        zoho_request('POST', '/invoices/' . urlencode($zoho_invoice_id) . '/email', [
            'send_from_org_email_id' => true,
            'to_mail_ids'            => [$member['email']],
            'subject'                => 'Your Buy Local Lowveld invoice',
            'body'                   => "Hi {$member['first_name']},\n\nThank you for your payment. Your invoice is attached.\n\nThe Buy Local Lowveld team",
        ], (int)$member['id']);
    }

    // ── 7. Save Zoho IDs back ────────────────────────────────
    db_exec(
        'UPDATE invoices
            SET zoho_invoice_id = :zi,
                zoho_invoice_number = :zn,
                zoho_payment_id = :zp,
                zoho_fee_expense_id = :zf,
                zoho_synced_at = NOW(),
                zoho_sync_error = NULL
          WHERE id = :id',
        [
            'zi' => $zoho_invoice_id,
            'zn' => $zoho_invoice_number ?: null,
            'zp' => $zoho_payment_id,
            'zf' => $zoho_fee_id ?: null,
            'id' => $local_invoice_id,
        ]
    );

    return [
        'ok'             => true,
        'invoice_id'     => $zoho_invoice_id,
        'invoice_number' => $zoho_invoice_number,
        'payment_id'     => $zoho_payment_id,
        'fee_id'         => $zoho_fee_id,
        'error'          => null,
    ];
}

/** Internal — record a sync error against the local invoice. */
function zoho_record_set_error(int $local_invoice_id, string $error): void {
    try {
        db_exec(
            'UPDATE invoices SET zoho_sync_error = :e WHERE id = :id',
            ['e' => mb_substr($error, 0, 1000), 'id' => $local_invoice_id]
        );
    } catch (Throwable $e) { /* ignore */ }
}

// ============================================================
//  Invoice fetching from Zoho — for displaying to members/admin
// ============================================================

/**
 * Fetch an invoice from Zoho as raw binary (PDF or HTML).
 * Returns ['ok'=>bool, 'status'=>int, 'body'=>string, 'content_type'=>string].
 *
 * $accept: 'pdf' or 'html'
 */
function zoho_fetch_invoice_raw(string $zoho_invoice_id, string $accept = 'pdf'): array {
    $token = zoho_access_token();
    if (!$token) {
        return ['ok'=>false, 'status'=>0, 'body'=>'', 'content_type'=>'', 'error'=>'No access token'];
    }
    if (!in_array($accept, ['pdf','html'], true)) {
        return ['ok'=>false, 'status'=>0, 'body'=>'', 'content_type'=>'', 'error'=>'Invalid accept type'];
    }

    $url = zoho_api_host() . '/books/v3/invoices/' . urlencode($zoho_invoice_id)
         . '?organization_id=' . ZOHO_ORG_ID
         . '&accept=' . $accept;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ['Authorization: Zoho-oauthtoken ' . $token],
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_HEADER         => true,  // we want headers to extract Content-Type
    ]);
    $response  = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $hsize     = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $err       = curl_error($ch);
    curl_close($ch);

    if ($response === false) {
        return ['ok'=>false, 'status'=>0, 'body'=>'', 'content_type'=>'', 'error'=>$err];
    }

    $headers_raw = substr($response, 0, $hsize);
    $body        = substr($response, $hsize);

    // Pull Content-Type out of headers
    $content_type = '';
    foreach (explode("\r\n", $headers_raw) as $line) {
        if (stripos($line, 'Content-Type:') === 0) {
            $content_type = trim(substr($line, strlen('Content-Type:')));
            break;
        }
    }

    $ok = ($http_code >= 200 && $http_code < 300) && $body !== '';

    // Don't log binary blobs to zoho_log (would bloat the table) — just log metadata
    try {
        db_exec(
            "INSERT INTO zoho_log (member_id, method, endpoint, http_status, success, request, response, note, occurred_at)
             VALUES (NULL, 'GET', :e, :s, :ok, NULL, NULL, :n, NOW())",
            [
                'e' => "/invoices/$zoho_invoice_id?accept=$accept",
                's' => (int)$http_code,
                'ok'=> $ok ? 1 : 0,
                'n' => $ok ? "Fetched invoice $accept (" . strlen($body) . " bytes)" : "Fetch failed",
            ]
        );
    } catch (Throwable $e) { /* ignore */ }

    return [
        'ok'           => $ok,
        'status'       => $http_code,
        'body'         => $body,
        'content_type' => $content_type ?: ($accept === 'pdf' ? 'application/pdf' : 'text/html'),
    ];
}

/**
 * List a contact's invoices on Zoho. Returns the invoices array
 * (each item has id, number, status, total, date, due_date, etc).
 */
function zoho_list_invoices_for_contact(string $contact_id): array {
    if (!zoho_is_configured() || $contact_id === '') return [];
    $r = zoho_request('GET', '/invoices?customer_id=' . urlencode($contact_id) . '&per_page=200&sort_column=date&sort_order=D');
    if (!$r['ok']) return [];
    return $r['data']['invoices'] ?? [];
}

/**
 * List recent invoices across the whole organisation (admin view).
 * @param int $page    1-based page
 * @param int $per_page  default 50, max 200
 */
/**
 * List recent invoices across the whole organisation (admin view).
 * Filters are pushed through to Zoho so they apply across all pages.
 *
 * @param int   $page       1-based page
 * @param int   $per_page   default 50, max 200
 * @param array $filters    Optional. Supported keys:
 *                          - status:        Sent | PartiallyPaid | Paid | Overdue | Draft | Void | Unpaid
 *                          - search:        free-text — matches invoice number OR customer name
 *                          - date_start:    YYYY-MM-DD
 *                          - date_end:      YYYY-MM-DD
 */
function zoho_list_invoices_all(int $page = 1, int $per_page = 50, array $filters = []): array {
    if (!zoho_is_configured()) return ['invoices'=>[], 'has_more_page'=>false];
    $per_page = max(1, min(200, $per_page));

    $params = [
        'page'        => $page,
        'per_page'    => $per_page,
        'sort_column' => 'date',
        'sort_order'  => 'D',
    ];

    // Status — Zoho expects specific casing. We accept lower-case keys and map.
    if (!empty($filters['status'])) {
        $map = [
            'sent'           => 'Sent',
            'paid'           => 'Paid',
            'unpaid'         => 'Unpaid',  // Zoho's "Unpaid" filter covers Sent/Viewed/Overdue/PartiallyPaid
            'partially_paid' => 'PartiallyPaid',
            'overdue'        => 'Overdue',
            'draft'          => 'Draft',
            'void'           => 'Void',
        ];
        $key = strtolower((string)$filters['status']);
        if (isset($map[$key])) {
            $params['status'] = $map[$key];
        }
    }

    if (!empty($filters['search'])) {
        // Zoho doesn't have an "any field contains" search, so we use the
        // invoice_number_contains filter — which also matches partial customer
        // names via invoice search internally on most setups. If admin needs
        // to search by customer name explicitly, we run a second query and merge.
        $params['search_text'] = $filters['search'];
    }

    if (!empty($filters['date_start']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['date_start'])) {
        $params['date_start'] = $filters['date_start'];
    }
    if (!empty($filters['date_end']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['date_end'])) {
        $params['date_end'] = $filters['date_end'];
    }

    $r = zoho_request('GET', '/invoices?' . http_build_query($params));
    if (!$r['ok']) return ['invoices'=>[], 'has_more_page'=>false];
    return [
        'invoices'      => $r['data']['invoices'] ?? [],
        'has_more_page' => (bool)($r['data']['page_context']['has_more_page'] ?? false),
        'page'          => (int)($r['data']['page_context']['page'] ?? $page),
    ];
}

/**
 * Get a single invoice's full detail from Zoho (line items, taxes, etc).
 * Used for read-only admin view of an invoice.
 */
function zoho_get_invoice(string $zoho_invoice_id): ?array {
    if (!zoho_is_configured() || $zoho_invoice_id === '') return null;
    $r = zoho_request('GET', '/invoices/' . urlencode($zoho_invoice_id));
    if (!$r['ok'] || empty($r['data']['invoice'])) return null;
    return $r['data']['invoice'];
}


/**
 * Build a customer statement from Zoho data.
 *
 * Zoho Books does not expose a public "statement PDF" API — the customer
 * statement is only available through the Zoho web UI. So we build our
 * own statement by pulling invoices + customer payments for the contact
 * and computing a running balance.
 *
 * Returns ['ok'=>bool, 'rows'=>array, 'opening_balance'=>float, 'closing_balance'=>float, 'totals'=>array, 'error'=>?string]
 * Each row: ['date','type','number','description','debit','credit','balance']
 */
function zoho_get_statement_data(string $contact_id, ?string $from_date = null, ?string $to_date = null): array {
    if (!zoho_is_configured() || $contact_id === '') {
        return ['ok'=>false, 'rows'=>[], 'opening_balance'=>0, 'closing_balance'=>0, 'totals'=>[], 'error'=>'Zoho not configured or no contact'];
    }

    $from_date = $from_date ?: date('Y-m-d', strtotime('-12 months'));
    $to_date   = $to_date   ?: date('Y-m-d');

    // Zoho's date_after / date_before filters are EXCLUSIVE (date_before=2026-04-28
    // excludes anything dated 28 Apr). Pad by 1 day on each side and we'll filter
    // back to the user's exact range after merging.
    $api_after  = date('Y-m-d', strtotime($from_date . ' -1 day'));
    $api_before = date('Y-m-d', strtotime($to_date   . ' +1 day'));

    // ── Pull invoices in range ──────────────────────────────
    $inv_qs = http_build_query([
        'customer_id' => $contact_id,
        'date_after'  => $api_after,
        'date_before' => $api_before,
        'per_page'    => 200,
        'sort_column' => 'date',
        'sort_order'  => 'A',
    ]);
    $r = zoho_request('GET', '/invoices?' . $inv_qs);
    if (!$r['ok']) {
        return ['ok'=>false, 'rows'=>[], 'opening_balance'=>0, 'closing_balance'=>0, 'totals'=>[], 'error'=>'Could not fetch invoices: HTTP '.$r['status']];
    }
    $invoices = $r['data']['invoices'] ?? [];

    // ── Pull customer payments in range ─────────────────────
    $pay_qs = http_build_query([
        'customer_id' => $contact_id,
        'date_after'  => $api_after,
        'date_before' => $api_before,
        'per_page'    => 200,
        'sort_column' => 'date',
        'sort_order'  => 'A',
    ]);
    $r = zoho_request('GET', '/customerpayments?' . $pay_qs);
    if (!$r['ok']) {
        return ['ok'=>false, 'rows'=>[], 'opening_balance'=>0, 'closing_balance'=>0, 'totals'=>[], 'error'=>'Could not fetch payments: HTTP '.$r['status']];
    }
    $payments = $r['data']['customerpayments'] ?? [];

    // ── Pull credit notes in range (optional, but nice for statement) ─
    $cn_qs = http_build_query([
        'customer_id' => $contact_id,
        'date_after'  => $api_after,
        'date_before' => $api_before,
        'per_page'    => 200,
        'sort_column' => 'date',
        'sort_order'  => 'A',
    ]);
    $r = zoho_request('GET', '/creditnotes?' . $cn_qs);
    $credit_notes = ($r['ok'] ? ($r['data']['creditnotes'] ?? []) : []);

    // ── Opening balance: pull invoices BEFORE from_date and sum unpaid ─
    // Simpler approximation: contact's outstanding receivable at from_date
    // We'll fetch the contact and use its current balance, then walk back.
    // For a v1 we'll just compute opening as 0 and let the running balance
    // be in-period only. Customers can adjust the date range to see further back.
    $opening_balance = 0.0;

    // ── Merge into a single chronological row list ──────────
    $rows = [];

    foreach ($invoices as $inv) {
        $rows[] = [
            'date'        => (string)($inv['date'] ?? ''),
            'type'        => 'invoice',
            'number'      => (string)($inv['invoice_number'] ?? ''),
            'zoho_id'     => (string)($inv['invoice_id'] ?? ''),
            'description' => 'Invoice',
            'debit'       => (float)($inv['total'] ?? 0),
            'credit'      => 0.0,
            'status'      => (string)($inv['status'] ?? ''),
        ];
    }
    foreach ($payments as $p) {
        $rows[] = [
            'date'        => (string)($p['date'] ?? ''),
            'type'        => 'payment',
            'number'      => (string)($p['payment_number'] ?? ''),
            'zoho_id'     => (string)($p['payment_id'] ?? ''),
            'description' => 'Payment received' . (!empty($p['payment_mode']) ? ' (' . $p['payment_mode'] . ')' : ''),
            'debit'       => 0.0,
            'credit'      => (float)($p['amount'] ?? 0),
            'status'      => '',
        ];
    }
    foreach ($credit_notes as $cn) {
        $rows[] = [
            'date'        => (string)($cn['date'] ?? ''),
            'type'        => 'credit_note',
            'number'      => (string)($cn['creditnote_number'] ?? ''),
            'zoho_id'     => (string)($cn['creditnote_id'] ?? ''),
            'description' => 'Credit note',
            'debit'       => 0.0,
            'credit'      => (float)($cn['total'] ?? 0),
            'status'      => (string)($cn['status'] ?? ''),
        ];
    }

    // Trim to the user's exact date range (we padded by ±1 day for the API).
    // Inclusive on both ends.
    $rows = array_values(array_filter($rows, function($r) use ($from_date, $to_date) {
        $d = $r['date'] ?? '';
        return $d !== '' && $d >= $from_date && $d <= $to_date;
    }));

    // Sort chronologically (date asc; ties: invoices before payments)
    usort($rows, function($a, $b) {
        $cmp = strcmp($a['date'], $b['date']);
        if ($cmp !== 0) return $cmp;
        $type_order = ['invoice'=>1, 'credit_note'=>2, 'payment'=>3];
        return ($type_order[$a['type']] ?? 9) <=> ($type_order[$b['type']] ?? 9);
    });

    // Compute running balance
    $bal = $opening_balance;
    foreach ($rows as &$r) {
        $bal += ($r['debit'] - $r['credit']);
        $r['balance'] = round($bal, 2);
    }
    unset($r);

    // Totals
    $totals = [
        'invoiced'    => array_sum(array_column(array_filter($rows, fn($x)=>$x['type']==='invoice'),    'debit')),
        'paid'        => array_sum(array_column(array_filter($rows, fn($x)=>$x['type']==='payment'),    'credit')),
        'credited'    => array_sum(array_column(array_filter($rows, fn($x)=>$x['type']==='credit_note'),'credit')),
    ];

    return [
        'ok'              => true,
        'rows'            => $rows,
        'opening_balance' => $opening_balance,
        'closing_balance' => round($bal, 2),
        'totals'          => $totals,
        'from_date'       => $from_date,
        'to_date'         => $to_date,
        'error'           => null,
    ];
}

// ============================================================
//  Bank accounts & transactions
// ============================================================

/**
 * List all bank/credit-card accounts on the Zoho organisation.
 * Returns an array of accounts with id, name, balance, currency, etc.
 */
function zoho_list_bank_accounts(): array {
    if (!zoho_is_configured()) return [];
    $r = zoho_request('GET', '/bankaccounts');
    if (!$r['ok']) return [];
    return $r['data']['bankaccounts'] ?? [];
}

/**
 * Get a single bank account by ID.
 */
function zoho_get_bank_account(string $account_id): ?array {
    if (!zoho_is_configured() || $account_id === '') return null;
    $r = zoho_request('GET', '/bankaccounts/' . urlencode($account_id));
    if (!$r['ok']) return null;
    return $r['data']['bankaccount'] ?? null;
}

/**
 * List bank transactions for a given account, with optional filters.
 *
 * @param string $account_id  Zoho bank account id
 * @param int    $page
 * @param int    $per_page
 * @param array  $filters     Optional: search, date_start, date_end, status
 *                            status: matched | manuallyadded | uncategorized
 */
function zoho_list_bank_transactions(string $account_id, int $page = 1, int $per_page = 50, array $filters = []): array {
    if (!zoho_is_configured() || $account_id === '') {
        return ['transactions'=>[], 'has_more_page'=>false];
    }
    $per_page = max(1, min(200, $per_page));

    $params = [
        'account_id'  => $account_id,
        'page'        => $page,
        'per_page'    => $per_page,
        'sort_column' => 'date',
        'sort_order'  => 'D',
    ];
    if (!empty($filters['search']))     $params['search_text']    = $filters['search'];
    if (!empty($filters['date_start']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['date_start'])) {
        $params['date_start'] = $filters['date_start'];
    }
    if (!empty($filters['date_end']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['date_end'])) {
        $params['date_end'] = $filters['date_end'];
    }
    if (!empty($filters['status'])) {
        // Allowed Zoho values vary; just pass through the user-selected value
        $params['status'] = $filters['status'];
    }

    $r = zoho_request('GET', '/banktransactions?' . http_build_query($params));
    if (!$r['ok']) return ['transactions'=>[], 'has_more_page'=>false];

    return [
        'transactions'  => $r['data']['banktransactions'] ?? [],
        'has_more_page' => (bool)($r['data']['page_context']['has_more_page'] ?? false),
        'page'          => (int)($r['data']['page_context']['page'] ?? $page),
    ];
}

// ============================================================
//  PayFast payouts — auto-balanced transfer + fee
// ============================================================

/**
 * Record a PayFast payout in Zoho Books.
 *
 * Creates two postings on the PayFast holding account:
 *   1. transfer_fund: PayFast → Real Bank for the gross payout amount
 *   2. expense:       PayFast → Bank Charges for the payout fee
 *
 * The matching deposit on the real bank account will arrive separately
 * via the bank feed and can later be matched against the transfer using
 * Zoho's matching API.
 *
 * Returns ['ok'=>bool, 'transfer_id'=>?string, 'expense_id'=>?string, 'error'=>?string].
 */
function zoho_record_payfast_payout(
    int    $payout_amount_cents,
    int    $fee_cents,
    string $payout_date,
    string $notes = ''
): array {
    if (!zoho_is_configured()) {
        return ['ok'=>false, 'error'=>'Zoho not configured'];
    }
    if ($payout_amount_cents <= 0) {
        return ['ok'=>false, 'error'=>'Payout amount must be positive'];
    }

    $payfast_account_id   = (string)setting_get('zoho.payfast_account_id', '');
    $real_bank_account_id = (string)setting_get('zoho.real_bank_account_id', '');

    if ($payfast_account_id === '') {
        return ['ok'=>false, 'error'=>'PayFast Zoho account not configured (Settings → Payment Fees)'];
    }
    if ($real_bank_account_id === '') {
        return ['ok'=>false, 'error'=>'Destination bank account not configured (Settings → Payment Fees)'];
    }
    if ($payfast_account_id === $real_bank_account_id) {
        return ['ok'=>false, 'error'=>'PayFast and destination accounts cannot be the same'];
    }

    $payout_amount = round($payout_amount_cents / 100, 2);
    $fee_amount    = round($fee_cents / 100, 2);
    $date          = preg_match('/^\d{4}-\d{2}-\d{2}$/', $payout_date) ? $payout_date : date('Y-m-d');
    $note_suffix   = $notes !== '' ? (' — ' . mb_substr($notes, 0, 200)) : '';

    // ── 1. Transfer fund: PayFast → Real bank ────────────────
    $ref = 'PAYOUT-' . str_replace('-', '', $date);
    $transfer_payload = [
        'from_account_id'  => $payfast_account_id,
        'to_account_id'    => $real_bank_account_id,
        'transaction_type' => 'transfer_fund',
        'amount'           => $payout_amount,
        'date'             => $date,
        'description'      => 'PayFast payout to bank' . $note_suffix,
        'reference_number' => $ref,
    ];
    $r = zoho_request('POST', '/banktransactions', $transfer_payload);
    if (!$r['ok']) {
        $msg = $r['data']['message'] ?? ('HTTP ' . $r['status']);
        return ['ok'=>false, 'error'=>'Transfer failed: ' . $msg];
    }
    $transfer_id = (string)(
        $r['data']['banktransaction']['transaction_id']
        ?? $r['data']['transaction_id']
        ?? ''
    );
    if ($transfer_id === '') {
        return ['ok'=>false, 'error'=>'Zoho returned no transaction_id for transfer'];
    }

    // ── 2. Fee expense from PayFast account ──────────────────
    $expense_id = '';
    if ($fee_cents > 0) {
        $fee_account_id = (string)setting_get('gateway_fee_account_id', '');
        if ($fee_account_id === '') {
            // Auto-detect "Bank Charges" expense account
            $exp = zoho_request('GET', '/chartofaccounts?filter_by=AccountType.Expense&per_page=200');
            if ($exp['ok']) {
                foreach (($exp['data']['chartofaccounts'] ?? []) as $coa) {
                    $name = strtolower($coa['account_name'] ?? '');
                    if (str_contains($name, 'bank charge') || str_contains($name, 'merchant fee') || str_contains($name, 'gateway fee') || str_contains($name, 'bank fee')) {
                        $fee_account_id = (string)$coa['account_id'];
                        break;
                    }
                }
            }
        }

        if ($fee_account_id !== '') {
            $exp_payload = [
                'account_id'              => $fee_account_id,
                'paid_through_account_id' => $payfast_account_id,
                'date'                    => $date,
                'amount'                  => $fee_amount,
                'is_inclusive_tax'        => true,
                'description'             => 'PayFast payout fee' . $note_suffix,
                'reference_number'        => 'PAYOUT-FEE-' . str_replace('-', '', $date),
            ];
            $r = zoho_request('POST', '/expenses', $exp_payload);
            if ($r['ok']) {
                $expense_id = (string)($r['data']['expense']['expense_id'] ?? '');
            }
            // If fee fails we don't fail the whole payout — transfer already created.
            // Return ok=true with empty expense_id and admin can retry from the list.
        }
    }

    return [
        'ok'          => true,
        'transfer_id' => $transfer_id,
        'expense_id'  => $expense_id,
        'error'       => null,
    ];
}

/**
 * Get matching candidate transactions for an uncategorized bank statement line.
 * Returns Zoho's suggested matches.
 */
function zoho_get_match_candidates(string $uncategorized_id): array {
    if (!zoho_is_configured() || $uncategorized_id === '') return [];
    $r = zoho_request('GET', '/banktransactions/uncategorizeds/' . urlencode($uncategorized_id) . '/match');
    if (!$r['ok']) return [];
    return $r['data']['matching_transactions'] ?? $r['data']['transactions'] ?? [];
}

/**
 * Match an uncategorized bank statement line against an existing Zoho transaction.
 * The $matches param is an array of ['transaction_id'=>..., 'transaction_type'=>...].
 */
function zoho_match_uncategorized(string $uncategorized_id, array $matches): array {
    if (!zoho_is_configured() || $uncategorized_id === '') {
        return ['ok'=>false, 'error'=>'Invalid arguments'];
    }
    $payload = ['transactions_to_be_matched' => $matches];
    $r = zoho_request('POST', '/banktransactions/uncategorizeds/' . urlencode($uncategorized_id) . '/match', $payload);
    if (!$r['ok']) {
        $msg = $r['data']['message'] ?? ('HTTP ' . $r['status']);
        return ['ok'=>false, 'error'=>$msg];
    }
    return ['ok'=>true];
}

/**
 * Categorize an uncategorized bank statement line as a transfer_fund.
 * Used as a fallback when no Zoho transaction exists yet to match against.
 */
function zoho_categorize_as_transfer(
    string $uncategorized_id,
    string $from_account_id,
    string $to_account_id,
    float  $amount,
    string $date,
    string $description = ''
): array {
    if (!zoho_is_configured() || $uncategorized_id === '') {
        return ['ok'=>false, 'error'=>'Invalid arguments'];
    }
    $payload = [
        'from_account_id'  => $from_account_id,
        'to_account_id'    => $to_account_id,
        'transaction_type' => 'transfer_fund',
        'amount'           => $amount,
        'date'             => $date,
        'description'      => $description,
    ];
    $r = zoho_request('POST', '/banktransactions/uncategorizeds/' . urlencode($uncategorized_id) . '/categorize', $payload);
    if (!$r['ok']) {
        $msg = $r['data']['message'] ?? ('HTTP ' . $r['status']);
        return ['ok'=>false, 'error'=>$msg];
    }
    return ['ok'=>true, 'transaction_id'=>(string)($r['data']['banktransaction']['transaction_id'] ?? '')];
}

// ============================================================
//  Member edits — Zoho sync & duplicate prevention
// ============================================================

/**
 * Pre-flight check before saving member edits.
 *
 * Verifies that the proposed changes:
 *   1. Have valid email format
 *   2. Don't collide with another local member (different ID, same email)
 *   3. Don't collide with another Zoho contact (different contact_id, same email)
 *
 * Use this BEFORE any DB writes so we can surface a clean error message to
 * the user without leaving things in an inconsistent state.
 *
 * @param int   $member_id  The member being edited
 * @param array $changes    Proposed values: email, first_name, last_name, business_name, phone
 * @return array ['ok'=>bool, 'error'=>?string, 'field'=>?string]
 *               'field' identifies which input the error attaches to ('email', 'business_name', etc.)
 */
function zoho_validate_member_changes(int $member_id, array $changes): array {
    // Required local row to know what's actually changing
    $current = db_row('SELECT * FROM members WHERE id = :id', ['id' => $member_id]);
    if (!$current) {
        return ['ok'=>false, 'error'=>'Member not found', 'field'=>null];
    }

    // ── Email validation + dupe check ────────────────────────
    if (array_key_exists('email', $changes)) {
        $new_email = strtolower(trim((string)$changes['email']));
        $cur_email = strtolower(trim((string)$current['email']));

        if ($new_email === '') {
            return ['ok'=>false, 'error'=>'Email is required', 'field'=>'email'];
        }
        if (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
            return ['ok'=>false, 'error'=>'That email address doesn\'t look right — please double-check it.', 'field'=>'email'];
        }

        if ($new_email !== $cur_email) {
            // Local dupe?
            $local_dupe = db_row(
                'SELECT id FROM members WHERE LOWER(email) = :e AND id <> :me LIMIT 1',
                ['e' => $new_email, 'me' => $member_id]
            );
            if ($local_dupe) {
                return ['ok'=>false, 'error'=>'Another member is already using that email address.', 'field'=>'email'];
            }

            // Zoho dupe? Only check if Zoho is configured AND the member is linked.
            // A member with no Zoho contact yet won't be hit by this — the contact
            // will be checked when it gets created later.
            if (zoho_is_configured()) {
                $zoho_dupe = zoho_find_contact_by_email($new_email);
                if ($zoho_dupe && !empty($zoho_dupe['contact_id'])) {
                    // It's a dupe ONLY if it's a different Zoho contact than ours.
                    $our_zoho_id = (string)($current['zoho_contact_id'] ?? '');
                    if ((string)$zoho_dupe['contact_id'] !== $our_zoho_id) {
                        return [
                            'ok'    => false,
                            'error' => 'That email is already linked to a different customer in our accounting system. Please use a different email or contact support.',
                            'field' => 'email',
                        ];
                    }
                }
            }
        }
    }

    // Required fields
    foreach (['first_name', 'last_name', 'business_name'] as $req) {
        if (array_key_exists($req, $changes) && trim((string)$changes[$req]) === '') {
            return ['ok'=>false, 'error'=>ucfirst(str_replace('_',' ',$req)) . ' is required', 'field'=>$req];
        }
    }

    return ['ok'=>true, 'error'=>null, 'field'=>null];
}

/**
 * Update an existing Zoho Books contact with new member details.
 *
 * Only fields actually present in $changes are sent to Zoho — partial
 * updates are safe.
 *
 * Returns ['ok'=>bool, 'error'=>?string].
 *
 * NOTE: This does NOT touch local DB. Caller is responsible for writing
 * to the local DB only after this returns ok=true (so we never end up
 * with local data drifted from Zoho).
 */
function zoho_update_contact(string $contact_id, array $changes, ?int $member_id = null): array {
    if (!zoho_is_configured()) {
        return ['ok'=>false, 'error'=>'Zoho not configured'];
    }
    if ($contact_id === '') {
        return ['ok'=>false, 'error'=>'No Zoho contact ID'];
    }

    // Build the contact_persons[] update only if any person field changed.
    // Zoho needs the contact_person_id for updates — fetch the primary one.
    $person_fields_changing = array_intersect_key($changes, array_flip(['email','first_name','last_name','phone']));

    $payload = [];

    if (array_key_exists('business_name', $changes)) {
        $payload['contact_name'] = (string)$changes['business_name'];
        $payload['company_name'] = (string)$changes['business_name'];
    }

    if (!empty($person_fields_changing)) {
        // Get the existing contact persons so we can target the primary one
        $r = zoho_request('GET', '/contacts/' . urlencode($contact_id) . '/contactpersons', null, $member_id);
        if (!$r['ok']) {
            return ['ok'=>false, 'error'=>'Could not load Zoho contact persons: HTTP ' . $r['status']];
        }
        $persons = $r['data']['contact_persons'] ?? [];
        $primary = null;
        foreach ($persons as $p) {
            if (!empty($p['is_primary_contact'])) { $primary = $p; break; }
        }
        if (!$primary && !empty($persons)) $primary = $persons[0];
        if (!$primary) {
            return ['ok'=>false, 'error'=>'No primary contact person on the Zoho contact'];
        }

        $person_payload = [
            'contact_person_id'  => (string)$primary['contact_person_id'],
            'is_primary_contact' => true,
        ];
        if (array_key_exists('first_name', $changes)) $person_payload['first_name'] = (string)$changes['first_name'];
        if (array_key_exists('last_name',  $changes)) $person_payload['last_name']  = (string)$changes['last_name'];
        if (array_key_exists('email',      $changes)) $person_payload['email']      = strtolower(trim((string)$changes['email']));
        if (array_key_exists('phone',      $changes)) $person_payload['phone']      = (string)$changes['phone'];

        $payload['contact_persons'] = [$person_payload];
    }

    if (empty($payload)) {
        // Nothing Zoho-relevant changed — that's fine, treat as success
        return ['ok'=>true, 'error'=>null];
    }

    $r = zoho_request('PUT', '/contacts/' . urlencode($contact_id), $payload, $member_id);
    if (!$r['ok']) {
        // Surface the actual Zoho message if present
        $msg = $r['data']['message'] ?? null;
        if (!$msg) {
            $body = json_decode($r['raw'] ?? '', true);
            $msg = $body['message'] ?? ('HTTP ' . $r['status']);
        }
        // Specific cases: invalid email
        if (stripos($msg, 'email') !== false && (stripos($msg, 'invalid') !== false || stripos($msg, 'not valid') !== false)) {
            return ['ok'=>false, 'error'=>'Zoho rejected the email address: ' . $msg, 'field'=>'email'];
        }
        return ['ok'=>false, 'error'=>$msg];
    }

    return ['ok'=>true, 'error'=>null];
}