<?php
/**
 * Copyright (c) 2026 AG Projects
 * https://ag-projects.com
 *
 * JSON account-info endpoint authenticated via HTTP Digest using the
 * same credentials as sip_settings_digest.phtml.
 *
 * Usage from a client that has the user's SIP account/password:
 *
 *   GET  /sylk_settings.phtml?realm=domain
 *        -> {"account":"...", "email":"...",
 *            "owner": {"username":"...", "first_name":"...",
 *                      "last_name":"...", "timezone":"...",
 *                      "mobile":"...", "email":"...",
 *                      "url":"https://mdns.sipthor.net"},
 *            "delete_request": null  |
 *                              {"client_request_id":"...",
 *                               "client_timestamp":"...",
 *                               "server_request_id":"...",
 *                               "ip":"...", "sip_account":"...",
 *                               "requester_entity":{...},
 *                               "expire_date":"YYYY-MM-DD HH:MM:SS",
 *                               "delete_id":"..."},
 *            "pstn": {"caller_id":"...", "balance":..., "currency":"...",
 *                     "prepaid":true|false, "enabled":true|false,
 *                     "today":{"debit":..., "credit":...}}}
 *
 *   POST /sylk_settings.phtml?realm=domain
 *        Body (application/x-www-form-urlencoded or JSON):
 *            action=set_caller_id&caller_id=%2B31...
 *        -> {"ok":true,"caller_id":"+31..."}
 *
 *   POST /sylk_settings.phtml?realm=domain
 *        Body:
 *            action=set_email&email=foo%40bar.com
 *        -> {"ok":true,"email":"foo@bar.com"} on success.
 *
 *   POST /sylk_settings.phtml?realm=domain
 *        Body:
 *            action=set_password&new_password=...
 *        -> {"ok":true} on success, {"error":"..."} on failure.
 *
 *   POST /sylk_settings.phtml?realm=domain
 *        Body:
 *            action=request_delete_account
 *        -> {"ok":true,"email":"foo@bar.com"} on success — the
 *           server has sent a confirmation email containing a link
 *           the user must click within 2 days to finalize deletion.
 *           Requires an email address on file and (for prepaid
 *           accounts) a clean balance history. Mirrors the
 *           "Identity" tab → "Delete account" flow in sip_settings.phtml.
 *
 *   POST /sylk_settings.phtml?realm=domain
 *        Body:
 *            action=cancel_delete_account
 *        -> {"ok":true,"changed":true} on success.
 *           Drops a pending delete request — clears the three
 *           account_delete_request* Preferences so the previously
 *           emailed confirmation link no longer validates. No-op
 *           (ok:true, changed:false) when nothing is pending.
 *
 *        Mirrors sip_settings.phtml's password-change path: respects
 *        the "deny-password-change" group, hashes via the same
 *        md5(user:domain:pw):md5(user@domain:domain:pw) scheme unless
 *        the engine stores cleartext, and pushes the change through
 *        the same NGNPro updateAccount SOAP call. After a successful
 *        change the client MUST re-register with the new credentials.
 *
 * The `realm` query parameter is required so that the WWW-Authenticate
 * challenge uses the SIP domain (e.g. "sylk.link") and the user's normal
 * SIP password works directly via the ha1 stored on the SIP server.
 */

require '/etc/cdrtool/global.inc';
require '/etc/cdrtool/ngnpro_engines.inc';
require 'sip_settings.php';

header('Content-Type: application/json; charset=utf-8');

function json_die($payload, $status = 200)
{
    http_response_code($status);
    // Pretty-print so the response is readable when the endpoint is
    // hit directly from a browser. JSON_UNESCAPED_SLASHES keeps URLs
    // legible; JSON_UNESCAPED_UNICODE avoids "é" style escapes
    // in display names / emails. Adds a trailing newline so curl
    // output doesn't run into the next shell prompt.
    echo json_encode(
        $payload,
        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
    ) . "\n";
    exit;
}

// Accept JSON bodies in addition to form-encoded ones.
$body = array();
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
    $ctype = isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : '';
    if (stripos($ctype, 'application/json') !== false) {
        $raw = file_get_contents('php://input');
        $decoded = json_decode($raw, true);
        if (is_array($decoded)) {
            $body = $decoded;
            // make params reachable from getSipAccountFromHTTPDigest() which reads $_REQUEST
            foreach ($body as $k => $v) {
                if (!isset($_REQUEST[$k])) $_REQUEST[$k] = $v;
            }
        }
    } else {
        $body = $_POST;
    }
}

// Realm normalization (sylk wire API):
//   • Accepted on the wire as ?realm=<domain> ONLY.
//   • Backward-compat: ?realm=domain is still split and the
//     domain part is used (the legacy library helper does this).
//   • Default when missing: "sylk.link". The legacy
//     sip_settings_digest.phtml path defaulted to "SIP_settings"
//     (web-password flow); we intentionally diverge here so the
//     mobile client doesn't have to know the realm at all in the
//     common case.
if (empty($_REQUEST['realm'])) {
    $_REQUEST['realm'] = 'sylk.link';
}

// Authenticate. getSipAccountFromHTTPDigest() handles 401 / WWW-Authenticate
// internally and die()s on failure, so on return we are authenticated.
$credentials = getSipAccountFromHTTPDigest();
if (!$credentials) {
    json_die(array('error' => 'auth_failed'), 401);
}

// Pick subscriber SipSettings class (same logic as sip_settings_digest.phtml).
$_class = !empty($CDRTool['sip_settings_class']) ? $CDRTool['sip_settings_class'] : 'SipSettings';
$_reseller_class = $_class . $credentials['reseller'];
$SipSettings_class = class_exists($_reseller_class) ? $_reseller_class : $_class;

$login_credentials = array(
    'reseller'       => $credentials['reseller'],
    'customer'       => $credentials['customer'],
    'login_type'     => 'subscriber',
    'sip_engine'     => $credentials['engine'],
    'templates_path' => './templates',
);

// Construct settings object — this populates the in-memory copies of
// rpid (exposed on the wire as pstn.caller_id), currency, etc.
$SipSettings = new $SipSettings_class(
    $credentials['account'],
    $login_credentials,
    $soapEngines
);

$action = isset($_REQUEST['action']) ? $_REQUEST['action'] : '';

logger(sprintf('sylk_settings.phtml: %s %s action=%s',
               $_SERVER['REQUEST_METHOD'],
               $credentials['account'],
               $action === '' ? '(snapshot)' : $action));

if ($action === 'set_caller_id') {
    $raw = isset($_REQUEST['caller_id']) ? (string) $_REQUEST['caller_id'] : '';

    $caller_id = preg_replace('/[^\+0-9]/', '', (string) $raw);
    if ($caller_id !== '' && strpos($caller_id, '+') === 0) {
        $caller_id = '00' . substr($caller_id, 1);
    } elseif ($caller_id !== '' && strpos($caller_id, '00') !== 0) {
        $caller_id = '00' . $caller_id;
    }

    if ($caller_id !== '' && !preg_match('/^00[1-9][0-9]{6,14}$/', $caller_id)) {
        json_die(array(
            'error'   => 'invalid_caller_id',
            'message' => 'Expected international format starting with + or 00, followed by 7-15 digits',
        ), 400);
    }

    $current = isset($SipSettings->rpid) ? (string) $SipSettings->rpid : '';
    if ($current === $caller_id) {
        json_die(array(
            'ok'        => true,
            'changed'   => false,
            'caller_id' => $caller_id,
        ));
    }

    $soap_result = $SipSettings->result;
    $soap_result->rpid = (string) $caller_id;

    $SipSettings->SipPort->addHeader($SipSettings->SoapAuth);
    $update = $SipSettings->SipPort->updateAccount($soap_result);

    if ((new PEAR)->isError($update)) {
        $fault = $update->getFault();
        json_die(array(
            'error'   => 'update_failed',
            'code'    => $update->getCode(),
            'message' => $update->getMessage(),
            'detail'  => isset($fault->detail->exception->errorstring)
                ? $fault->detail->exception->errorstring : null,
        ), 502);
    }

    logger(sprintf('sylk_settings.phtml: %s set caller_id to %s',
                   $credentials['account'], $caller_id));

    json_die(array(
        'ok'        => true,
        'changed'   => true,
        'caller_id' => $caller_id,
    ));
}

if ($action === 'set_email') {
    $raw = isset($_REQUEST['email']) ? trim((string) $_REQUEST['email']) : '';

    // Empty value clears the field on the SIP account. Otherwise
    // enforce a minimal RFC-5322-ish shape — full validation is the
    // server / mail relay's problem, this is just to keep junk out.
    if ($raw !== '' && !filter_var($raw, FILTER_VALIDATE_EMAIL)) {
        json_die(array(
            'error'   => 'invalid_email',
            'message' => 'Invalid email address',
        ), 400);
    }

    if ($raw === (string) $SipSettings->email) {
        json_die(array(
            'ok'      => true,
            'changed' => false,
            'email'   => $raw,
        ));
    }

    $soap_result = $SipSettings->result;
    $soap_result->email = $raw;
    if (!$soap_result->quota) {
        $soap_result->quota = 0;
    }

    $SipSettings->SipPort->addHeader($SipSettings->SoapAuth);
    $update = $SipSettings->SipPort->updateAccount($soap_result);

    if ((new PEAR)->isError($update)) {
        $fault = $update->getFault();
        json_die(array(
            'error'   => 'update_failed',
            'code'    => $update->getCode(),
            'message' => $update->getMessage(),
            'detail'  => isset($fault->detail->exception->errorstring)
                ? $fault->detail->exception->errorstring : null,
        ), 502);
    }

    logger(sprintf('sylk_settings.phtml: %s set SIP account email to %s',
                   $credentials['account'], $raw));

    json_die(array(
        'ok'      => true,
        'changed' => true,
        'email'   => $raw,
    ));
}

if ($action === 'set_password') {
    $new_password = isset($_REQUEST['new_password']) ? (string) $_REQUEST['new_password'] : '';

    // Strength check — mirrored by validateSipPassword on the
    // mobile side (app/accountInfo.js). The proxy doesn't enforce
    // any strength rule of its own, so we are the authoritative
    // gate. Reject any password that fails:
    //   • length ≥ 6
    //   • at least one lowercase letter
    //   • at least one uppercase letter
    //   • at least one digit
    if (strlen($new_password) < 6
        || !preg_match('/[a-z]/', $new_password)
        || !preg_match('/[A-Z]/', $new_password)
        || !preg_match('/\d/',    $new_password)) {
        json_die(array(
            'error'   => 'weak_password',
            'message' => 'Password must be at least 6 characters, with upper case, lower case, and a number.',
        ), 400);
    }

    // Honour the "deny-password-change" group on the SIP account, the
    // same gate sip_settings.phtml uses (library/sip_settings.php:4278).
    $groups = is_array($SipSettings->groups) ? $SipSettings->groups : array();
    if (in_array('deny-password-change', $groups, true)) {
        json_die(array(
            'error'   => 'change_denied',
            'message' => 'Password change is disabled for this account',
        ), 403);
    }

    $soap_result = $SipSettings->result;

    // Same hashing as sip_settings.php:4279-4290. The cleartext branch
    // only fires when the SOAP engine declares it stores plaintext;
    // otherwise we send the pair of HA1 digests the SIP database
    // expects (one for bare username, one for full AOR username).
    if (!empty($SipSettings->store_clear_text_passwords)) {
        $soap_result->password = $new_password;
    } else {
        $u  = strtolower($SipSettings->username);
        $d  = strtolower($SipSettings->domain);
        $h1 = md5($u . ':' . $d . ':' . $new_password);
        $h2 = md5($u . '@' . $d . ':' . $d . ':' . $new_password);
        $soap_result->password = $h1 . ':' . $h2;
    }

    if (!$soap_result->quota) {
        $soap_result->quota = 0;
    }

    $SipSettings->SipPort->addHeader($SipSettings->SoapAuth);
    $update = $SipSettings->SipPort->updateAccount($soap_result);

    if ((new PEAR)->isError($update)) {
        $fault = $update->getFault();
        json_die(array(
            'error'   => 'update_failed',
            'code'    => $update->getCode(),
            'message' => $update->getMessage(),
            'detail'  => isset($fault->detail->exception->errorstring)
                ? $fault->detail->exception->errorstring : null,
        ), 502);
    }

    logger(sprintf('sylk_settings.phtml: %s changed SIP password',
                   $credentials['account']));

    json_die(array(
        'ok'      => true,
        'changed' => true,
    ));
}

if ($action === 'cancel_delete_account') {
    // Drop a pending delete request. Clears the three Preferences
    // that gate the click-through validator:
    //   • account_delete_request_id  (matched against ?delete_id=)
    //   • account_delete_request     (presence flag + timestamp)
    //   • account_delete_request_info (structured record / wire JSON)
    // After this returns, the email link previously sent becomes
    // useless: the validator at sip_settings.php ~line 11462 short-
    // circuits on empty($account_delete_request). The new
    // Delete-account modal can re-request later if the user
    // changes their mind.
    $had_request = !empty($SipSettings->Preferences['account_delete_request'])
                || !empty($SipSettings->Preferences['account_delete_request_id'])
                || !empty($SipSettings->Preferences['account_delete_request_info']);

    if (!$had_request) {
        json_die(array(
            'ok'      => true,
            'changed' => false,
            'message' => 'No pending delete request to cancel',
        ));
    }

    // ID-match check. The client sends client_request_id (the
    // only ID it knows — server_request_id is the secret URL
    // token in the email and never leaves the server otherwise).
    // We compare against the persisted client_request_id inside
    // account_delete_request_info to make sure the client is
    // aborting the request it thinks it's aborting (protects
    // against stale clients cancelling a freshly-issued request
    // from another device).
    //
    // Sending no ID is allowed (lets the web Identity tab call
    // this without a snapshot round-trip), logged for visibility.
    // Sending an ID that doesn't match → 409.
    $client_provided_client_id = isset($_REQUEST['client_request_id'])
        ? trim((string) $_REQUEST['client_request_id']) : '';

    $persisted_info_raw = isset($SipSettings->Preferences['account_delete_request_info'])
        ? (string) $SipSettings->Preferences['account_delete_request_info'] : '';
    $persisted_info = $persisted_info_raw !== ''
        ? json_decode($persisted_info_raw, true) : null;
    $persisted_client_id = (is_array($persisted_info)
        && isset($persisted_info['client_request_id']))
        ? (string) $persisted_info['client_request_id'] : '';

    if ($client_provided_client_id !== '') {
        if ($client_provided_client_id !== $persisted_client_id) {
            logger(sprintf('cancel_delete_account: %s ID MISMATCH — client sent client_id=%s, persisted client_id=%s',
                           $credentials['account'],
                           $client_provided_client_id,
                           $persisted_client_id ?: '(none)'));
            json_die(array(
                'error'   => 'request_id_mismatch',
                'message' => 'The delete request you are trying to abort does not match the one currently pending. Refresh and try again.',
                'pending' => array(
                    'client_request_id' => $persisted_client_id,
                ),
            ), 409);
        }
        logger(sprintf('cancel_delete_account: %s client_id match confirmed (%s)',
                       $credentials['account'],
                       $persisted_client_id));
    } else {
        logger(sprintf('cancel_delete_account: %s no client_id provided — proceeding without match check (persisted client_id=%s)',
                       $credentials['account'],
                       $persisted_client_id ?: '(none)'));
    }

    // Rebuild $this->properties as a clean list of {name,value}
    // arrays, filtering out the three account_delete_request* keys.
    // Entries arriving from getAccount() are SOAP objects; entries
    // rebuilt by setPreference are arrays. NGNPro's updateAccount
    // rejects the mixed shape with "PropertyArray should be an
    // array" — normalize to all-arrays to keep the wire payload
    // uniform. The keyed-hash $this->Preferences is cleared in the
    // same loop so any same-request reads see the new state.
    $drop_keys = array(
        'account_delete_request',
        'account_delete_request_id',
        'account_delete_request_info',
    );
    $normalized = array();
    if (is_array($SipSettings->properties)) {
        foreach ($SipSettings->properties as $_p) {
            $name = null; $value = null;
            if (is_object($_p)) {
                $name  = isset($_p->name)  ? $_p->name  : null;
                $value = isset($_p->value) ? $_p->value : null;
            } elseif (is_array($_p)) {
                $name  = isset($_p['name'])  ? $_p['name']  : null;
                $value = isset($_p['value']) ? $_p['value'] : null;
            }
            if ($name === null) continue;
            if (in_array($name, $drop_keys, true)) continue;
            $normalized[] = array(
                'name'  => (string) $name,
                'value' => (string) ($value === null ? '' : $value),
            );
        }
    }
    $SipSettings->properties = $normalized;
    foreach ($drop_keys as $_pref) {
        if (isset($SipSettings->Preferences[$_pref])) {
            unset($SipSettings->Preferences[$_pref]);
        }
    }

    // Push the trimmed properties array to NGNPro.
    $soap_result = $SipSettings->result;
    $soap_result->properties = $normalized;
    if (!$soap_result->quota) $soap_result->quota = 0;
    $SipSettings->SipPort->addHeader($SipSettings->SoapAuth);
    $update = $SipSettings->SipPort->updateAccount($soap_result);
    if ((new PEAR)->isError($update)) {
        $fault = $update->getFault();
        logger(sprintf('cancel_delete_account: %s FAILED — code=%s message=%s detail=%s',
                       $credentials['account'],
                       $update->getCode(),
                       $update->getMessage(),
                       isset($fault->detail->exception->errorstring)
                           ? $fault->detail->exception->errorstring : '(none)'));
        json_die(array(
            'error'   => 'cancel_failed',
            'code'    => $update->getCode(),
            'message' => $update->getMessage(),
        ), 502);
    }

    logger(sprintf('sylk_settings.phtml: %s cancelled pending account deletion',
                   $credentials['account']));

    json_die(array(
        'ok'      => true,
        'changed' => true,
    ));
}

if ($action === 'request_delete_account') {
    // Mirror of the "Identity" tab → "Delete account" button in
    // sip_settings.phtml: triggers SipSettings::deleteAccount() with
    // skip_html=true so it returns 1 on success instead of writing
    // HTML to the response. The server-side method:
    //   • Refuses if there's any balance history for a subscriber
    //     (refund must be processed first).
    //   • Refuses if no email address is on file.
    //   • Generates a one-time delete_id valid for 2 days, persists
    //     it under the account_delete_request_id preference, and
    //     emails a confirmation link to the user. The actual
    //     account removal happens when the user clicks that link
    //     (handled in renderUI at sip_settings.php:11322).
    $owner_email = isset($SipSettings->owner_information['email'])
        ? trim((string) $SipSettings->owner_information['email']) : '';
    if ($owner_email === '') {
        json_die(array(
            'error'   => 'email_required',
            'message' => 'Set an email address on your customer profile before requesting deletion.',
        ), 400);
    }

    // Balance history check is intentionally OMITTED on the JSON
    // API path. Balance / refund accounting lives in upstream
    // billing systems and shouldn't gate the SIP-layer deletion
    // here — the legacy library/sip_settings.php gate is kept for
    // the web UI flow to preserve existing operator workflows,
    // but new mobile clients call this endpoint directly.

    // Honour the "deny-account-delete" pattern used elsewhere
    // (subscriber-only flag, set by reseller). The SOAP method
    // itself doesn't enforce it, so we mirror the sip_settings
    // group check.
    $delete_groups = is_array($SipSettings->groups) ? $SipSettings->groups : array();
    if (in_array('deny-account-delete', $delete_groups, true)) {
        json_die(array(
            'error'   => 'delete_denied',
            'message' => 'Account deletion is disabled for this account.',
        ), 403);
    }

    // Parse the optional caller-supplied device/identity blob.
    // Older / third-party clients won't send this — in that case
    // the email falls back to the legacy "from IP address X" line
    // only, with no request-details table (set $requester_entity
    // empty so the template's {if} block is skipped).
    $requester_entity = array();
    $raw_re = isset($_REQUEST['requester_entity']) ? (string) $_REQUEST['requester_entity'] : '';
    if ($raw_re !== '') {
        $decoded = json_decode($raw_re, true);
        if (is_array($decoded)) {
            foreach ($decoded as $k => $v) {
                if (is_scalar($v) && trim((string) $v) !== '') {
                    $requester_entity[(string) $k] = (string) $v;
                }
            }
        }
    }

    // Correlation IDs / timestamps. Client half is what the device
    // sent in this POST (may be empty for legacy callers); server
    // half is minted unconditionally so every delete request has an
    // audit row to grep for.
    $client_request_id = isset($_REQUEST['client_request_id'])
        ? trim((string) $_REQUEST['client_request_id']) : '';
    $client_timestamp  = isset($_REQUEST['client_timestamp'])
        ? trim((string) $_REQUEST['client_timestamp']) : '';
    // UUID v4 (Math.rand-based, no crypto dependency) so the server
    // ID has the same shape as the client one. mt_rand is plenty
    // for a correlation handle — the security-sensitive token is
    // the separate delete_id used in the confirmation link.
    $server_request_id = sprintf(
        '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
        mt_rand(0, 0xffff), mt_rand(0, 0xffff),
        mt_rand(0, 0xffff),
        mt_rand(0, 0x0fff) | 0x4000,           // version 4
        mt_rand(0, 0x3fff) | 0x8000,           // variant 1
        mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
    );

    // "Rich client" = caller sent at least one of the new metadata
    // fields. Only rich-client requests get the in-email request-
    // details table; legacy callers see the original layout. The
    // persistence path doesn't care — both flavours land in
    // account_delete_request_info so support can audit every
    // request regardless of how it came in.
    $is_rich_client = (count($requester_entity) > 0)
        || ($client_request_id !== '')
        || ($client_timestamp !== '');

    // Compose the audit record that deleteAccount() persists on
    // the account Preferences. The ip field overrides any
    // client-sent value (the device can't know its own NAT-
    // translated address; REMOTE_ADDR is authoritative). Single
    // timestamp — client_timestamp — captures when the user hit
    // the button. Server-side roundtrip is sub-second so a
    // separate server_timestamp adds no useful information.
    $request_record = array(
        'client_request_id' => $client_request_id,
        'client_timestamp'  => $client_timestamp,
        'server_request_id' => $server_request_id,
        'ip'                => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
        'sip_account'       => $credentials['account'],
        'requester_entity'  => $requester_entity,
    );

    if ($is_rich_client) {
        $requester_entity['ip']                = $request_record['ip'];
        $requester_entity['client_request_id'] = $client_request_id;
        $requester_entity['client_timestamp']  = $client_timestamp;
        $requester_entity['server_request_id'] = $server_request_id;
    }

    $SipSettings->requester_entity      = $requester_entity;
    $SipSettings->delete_request_record = $request_record;

    logger(sprintf('sylk_settings.phtml: delete request client_id=%s server_id=%s ts=%s',
                   $client_request_id ?: '(none)',
                   $server_request_id,
                   $client_timestamp ?: '(none)'));
    // Full enumeration of every key the client sent — useful when
    // troubleshooting which UA / app version a particular delete
    // request came from. We split it into top-level fields and the
    // nested requester_entity so the log line is scannable. Values
    // are truncated to 200 chars each to keep ridiculously long UA
    // strings from blowing up the syslog buffer.
    $log_keys = array();
    $log_keys[] = 'client_request_id=' . ($client_request_id ?: '(none)');
    $log_keys[] = 'client_timestamp='  . ($client_timestamp  ?: '(none)');
    if (count($requester_entity) > 0) {
        foreach ($requester_entity as $k => $v) {
            if ($k === 'client_request_id' || $k === 'client_timestamp'
                || $k === 'server_request_id' || $k === 'server_timestamp'
                || $k === 'ip') {
                continue;  // already in the line above / server-augmented
            }
            $vstr = (string) $v;
            if (strlen($vstr) > 200) $vstr = substr($vstr, 0, 200) . '...';
            $log_keys[] = $k . '=' . $vstr;
        }
    }
    logger(sprintf('sylk_settings.phtml: delete request inputs from %s: %s',
                   $credentials['account'], implode(' | ', $log_keys)));

    $_origEmail = $SipSettings->email;
    $SipSettings->email = $owner_email;
    $ok = $SipSettings->deleteAccount(true);
    $SipSettings->email = $_origEmail;

    if (!$ok) {
        // deleteAccount returns false for the no-email / no-template
        // / SOAP-error paths. The pre-flight above already handled
        // the no-email case so this branch usually means the email
        // template was missing or the mail relay refused the send.
        json_die(array(
            'error'   => 'delete_request_failed',
            'message' => 'Could not send the confirmation email. Try again later.',
        ), 502);
    }

    logger(sprintf('sylk_settings.phtml: %s requested account deletion (email sent to %s)',
                   $credentials['account'], $owner_email));

    json_die(array(
        'ok'                => true,
        'email'             => $owner_email,
        // server_request_id is intentionally NOT returned — it's
        // the secret click-through URL token, knowledge of which
        // proves access to the confirmation email. Only the
        // client_request_id is echoed back; the client uses it
        // for correlation and as the abort handle.
        'client_request_id' => $client_request_id,
        'client_timestamp'  => $client_timestamp,
    ));
}

// Default: return the account snapshot.
//
// getPrepaidStatus() raises a SOAP fault "Not a prepaid account
// (1051)" for postpaid accounts, and the library's
// checkPrintSoapError helper prints the fault as HTML to the
// response — leaking a <div class="alert alert-error">…</div>
// before the JSON body. Two defences:
//   1. Only call getPrepaidStatus() when the account IS prepaid
//      ($SipSettings->prepaid is populated from the SOAP account
//      record well before this point).
//   2. Buffer + discard any output during the balance fetches
//      anyway, so any future helper that prints stays out of the
//      JSON stream.
ob_start();
if (!empty($SipSettings->prepaid)) {
    $SipSettings->getPrepaidStatus();
}
$SipSettings->getBalanceHistory();
$today = $SipSettings->getTodayBalanceSummary();
// Last 2 weeks of call records, up to 50 placed + 50 received.
// Matches the data returned by settings-webrtc.phtml ?action=get_history.
$SipSettings->getHistory('completed');
ob_end_clean();

// allow_delete advertises that this API version SUPPORTS the in-app
// delete-on-server flow (action=request_delete_account). It is a
// capability flag of the endpoint, not a per-account permission
// check — older deployments without this script just won't have the
// flag, so the mobile client treats its absence (or false) as
// "fall back to deleteAccountUrl". Per-account gating (email on
// file, balance history, deny-account-delete group) happens at
// request time inside the action handler above; the mobile UI
// learns the precise reason from the server's error envelope and
// surfaces it to the user.
$allow_delete = true;

// PSTN-related flags. `pstn_access` is the proxy / reseller-level flag
// that says "this account may dial PSTN at all" (library/sip_settings.php
// uses the same field to decide whether to render the PSTN tab and the
// "Pay-as-you-go" UI). `account_type` distinguishes prepaid (top-up
// debits drawn from a balance) from postpaid (billed after the fact);
// the underlying $SipSettings->prepaid is an int — 1 for prepaid, 0
// for postpaid. We expose both an int-ish boolean and the string label
// so the mobile UI doesn't have to know the encoding.
$has_prepaid_account = !empty($SipSettings->prepaidAccount);
$balance = $has_prepaid_account && isset($SipSettings->prepaidAccount->balance)
    ? floatval($SipSettings->prepaidAccount->balance)
    : null;
$is_prepaid  = !empty($SipSettings->prepaid);
// PSTN dialling is allowed only when BOTH:
//   1. The engine/reseller has PSTN configured at all
//      ($SipSettings->pstn_access — populated from
//      soapEngines[...]['pstn_access'] / resellerProperties).
//   2. The subscriber is a member of the "free-pstn" group on the
//      SIP account. This is what the "PSTN access" checkbox on the
//      sip_settings.phtml UI toggles — checked = add to free-pstn,
//      unchecked = removeFromGroup(...,"free-pstn"). Without it the
//      proxy refuses outbound PSTN regardless of credit / quota.
// We need both true for the mobile app to safely show the PSTN UI.
$pstn_groups = is_array($SipSettings->groups) ? $SipSettings->groups : array();
$pstn_enabled = !empty($SipSettings->pstn_access)
                && in_array('free-pstn', $pstn_groups, true);

json_die(array(
    'account' => $credentials['account'],
    // Server-stored email. The client treats this as authoritative
    // (server wins), so include it in every snapshot — even when
    // empty — so a clear on the server propagates to the device.
    'email'   => (string) $SipSettings->email,
    'owner' => (function () use ($SipSettings) {
        $oi = is_array($SipSettings->owner_information)
            ? $SipSettings->owner_information : array();
        $map = array(
            'username'   => 'username',
            'first_name' => 'firstName',
            'last_name'  => 'lastName',
            'timezone'   => 'timezone',
            'mobile'     => 'mobile',
            'email'      => 'email',
        );
        $out = array();
        foreach ($map as $out_key => $src_key) {
            $out[$out_key] = isset($oi[$src_key]) ? (string) $oi[$src_key] : '';
        }
        $out['url'] = 'https://mdns.sipthor.net';
        return $out;
    })(),
    // properties is the SIP-account Preference bag. We strip the
    // internal account_delete_request_* storage keys here and
    // surface them under the structured `delete_request` section
    // below — clients shouldn't have to reach into properties for
    // them. The web UI uses these as raw fields, so they stay on
    // disk; we only hide them from the wire response.
    'properties' => (function () use ($SipSettings) {
        if (!is_array($SipSettings->Preferences)) return new stdClass();
        $out = $SipSettings->Preferences;
        unset($out['account_delete_request']);
        unset($out['account_delete_request_id']);
        unset($out['account_delete_request_info']);
        return $out;
    })(),
    // Structured record of the pending delete request, if any.
    // Populated by deleteAccount() at request time and persisted
    // as the account_delete_request_info Preference. JSON is
    // re-decoded here so the client sees a dict directly. Null
    // when no delete has been requested.
    //
    // TEMPORARY one-shot cleanup (REMOVE AFTER USERS HAVE HIT THE
    // SNAPSHOT ONCE): legacy records written before the structured
    // shape had server_request_id / client_request_id only carry
    // expire_date + delete_id. Detect those and call
    // abortDeleteRequest() to wipe the three Preferences so the
    // snapshot stops returning the malformed record forever. Once
    // every active subscriber has hit the snapshot post-deploy,
    // revert this closure to the simple read-only version.
    'delete_request' => (function () use ($SipSettings) {
        if (!is_array($SipSettings->Preferences)) return null;
        $blob = isset($SipSettings->Preferences['account_delete_request_info'])
            ? $SipSettings->Preferences['account_delete_request_info'] : '';
        if (!is_string($blob) || $blob === '') return null;
        $decoded = json_decode($blob, true);
        if (!is_array($decoded)) return null;

        // Legacy-record detector — no server_request_id means it
        // pre-dates the structured shape. Wipe + return null.
        if (empty($decoded['server_request_id'])
            || empty($decoded['client_request_id'])) {
            logger(sprintf('sylk_settings.phtml: %s legacy delete_request detected (keys=[%s]) — cleaning up',
                           $SipSettings->account,
                           implode(',', array_keys($decoded))));
            $ok = method_exists($SipSettings, 'abortDeleteRequest')
                ? $SipSettings->abortDeleteRequest() : false;
            logger(sprintf('sylk_settings.phtml: %s legacy cleanup %s',
                           $SipSettings->account,
                           $ok ? 'OK' : 'FAILED'));
            return null;
        }
        // STRIP server_request_id from the wire response. This
        // UUID is the click-through URL token — possession of it
        // proves the requester read the confirmation email, so it
        // must never be returned through any non-email channel.
        // Same rationale strips it from any nested copy inside
        // requester_entity (defence-in-depth in case a future
        // code path accidentally splices it in).
        unset($decoded['server_request_id']);
        if (isset($decoded['requester_entity']) && is_array($decoded['requester_entity'])) {
            unset($decoded['requester_entity']['server_request_id']);
        }
        return $decoded;
    })(),
    // True when the in-app delete-on-server flow is allowed (email
    // set, clean balance, not in deny-account-delete group). When
    // false the client should fall back to opening the legacy
    // deleteAccountUrl in an external browser, or hide the action.
    'allow_delete' => $allow_delete,
    // Full SIP-account group membership. Surfaced as an array so the
    // mobile client can check any membership directly (free-pstn,
    // deny-password-change, trunking, blocked, quota, payments, …)
    // without us minting a new boolean for each one. pstn.enabled
    // below is derived from this list AND the engine-level
    // pstn_access flag. NGNPro sometimes returns empty strings in
    // the groups list (placeholder rows / trailing slot); we filter
    // those out so the client doesn't have to.
    'groups'  => array_values(array_filter(
        $pstn_groups,
        function ($g) { return is_string($g) && trim($g) !== ''; }
    )),
    // Read-only server-side topology:
    //   sip_proxy  — the SIP proxy host the account is provisioned
    //                on (from soapEngines + reseller overrides).
    //   thor_node  — the SipThor home node currently hosting this
    //                account, looked up over the proxy's lookup
    //                socket (port 9500). Null when thor isn't
    //                enabled on this engine or when the lookup
    //                fails (proxy down / no response).
    'server' => array(
        'sip_proxy' => (string) $SipSettings->sip_proxy,
        'thor_node' => (function () use ($SipSettings) {
            if (empty($SipSettings->enable_thor)) return null;
            $node = @getSipThorHomeNode($SipSettings->account, $SipSettings->sip_proxy);
            if (!is_string($node)) return null;
            $node = trim($node);
            return $node === '' ? null : $node;
        })(),
    ),
    // All PSTN-related fields live under a single object so the
    // client can pass the whole bag around without enumerating keys.
    //   caller_id          — number presented as From: on outgoing
    //     PSTN calls. Stored as the account's `rpid` field on the
    //     SIP server (same field sip_settings.phtml's PSTN tab
    //     edits as "CLI").
    //   balance / currency — current prepaid balance (null when not
    //     a prepaid account).
    //   prepaid            — true when this is a prepaid account.
    //   enabled            — whether PSTN dialling is allowed at all
    //     (the reseller / proxy gate; false hides dial UI regardless
    //     of balance).
    //   today              — sum of today's debits and credits, for
    //     the at-a-glance line on the My Account modal.
    'pstn'    => array(
        // PSTN caller-Id ("Remote Party ID" in SIP terms — rpid on
        // the SOAP account record). This is what the SIP proxy
        // presents as the From: number on outgoing PSTN calls and
        // what sip_settings.phtml exposes under the PSTN tab as the
        // "CLI" / caller-Id field. Not the same as the
        // `mobile_number` Preference (used for ENUM / receipts).
        'caller_id' => (string) $SipSettings->rpid,
        // balance is the prepaid remaining credit. Null on postpaid
        // accounts — see quota / quota_usage below for the postpaid
        // equivalent.
        'balance'   => $balance,
        'currency'  => (string) $SipSettings->currency,
        'prepaid'   => $is_prepaid,
        // True only when the engine permits PSTN AND the subscriber
        // is in the "free-pstn" group. The mobile UI should hide
        // dialer / contact-call affordances when this is false.
        'enabled'   => $pstn_enabled,
        // Postpaid-only — the user's monthly call quota in
        // `currency`. Null on prepaid accounts (where `balance` is
        // the relevant field).
        'quota'       => (!$is_prepaid && isset($SipSettings->quota))
            ? floatval($SipSettings->quota) : null,
        'today'     => array(
            'debit'  => isset($today['debit'])  ? floatval($today['debit'])  : 0,
            'credit' => isset($today['credit']) ? floatval($today['credit']) : 0,
        ),
    ),
    // Recent SIP call history (placed + received). Last in the
    // payload because each entry is bulky — the small scalar
    // identity / pstn fields above stay near the top where they
    // read at a glance. Last 2 weeks, up to 50 per direction,
    // completed only. Same shape returned by
    // settings-webrtc.phtml?action=get_history.
    'call_history' => is_array($SipSettings->call_history)
        ? $SipSettings->call_history
        : array('placed' => array(), 'received' => array()),
));
