<?php
/* ============================================================================
   WBMCZ — SMS & OTP
   ----------------------------------------------------------------------------
   One place sends every text message and looks after every one-time password.

     sms_send($mobile, $text, $purpose)      send one SMS, log it, return the result
     sms_balance()                           how many credits are left
     otp_send($purpose, $mobile, $vars, $payload)
                                             make an OTP, text it, remember it
     otp_check($purpose, $mobile, $code)     is that the right code?
     otp_state($purpose, $mobile)            what the screen needs to know
     sms_cfg('sms_sender_id')                one setting, cached

   How an OTP is kept safe
     · the code itself is hashed before it is stored, never written in plain
     · it dies after the number of minutes you set
     · five wrong guesses and that code is burnt
     · a new code cannot be asked for until the cooldown has passed
     · one phone number only gets so many codes a day

   Test mode
     Switch it on in Admin → Website Settings → SMS & OTP and no text is sent —
     the code appears on the screen instead. Useful before the DLT header is
     approved, or while you are trying the flow yourself.
   ============================================================================ */

if (!function_exists('sms_cfg')) {

/* ---------------------------------------------------------------------------
   Settings, read once per request
   --------------------------------------------------------------------------- */
function sms_defaults() {
    return [
        'sms_enabled'          => '1',
        'sms_test_mode'        => '0',
        'sms_api_url'          => 'http://sms.hspsms.com',
        'sms_username'         => '',
        'sms_apikey'           => '',
        'sms_sender_id'        => '',
        'sms_type'             => 'TRANS',
        'sms_entity_id'        => '',
        'otp_length'           => '6',
        'otp_expiry_minutes'   => '10',
        'otp_max_attempts'     => '5',
        'otp_resend_seconds'   => '60',
        'otp_max_per_day'      => '10',
        'otp_register_enabled' => '1',
        'otp_reset_enabled'    => '1',
        'reset_email_enabled'  => '1',
        'sms_welcome_enabled'  => '1',
        'sms_tpl_otp_register' => '{otp} is your OTP to verify your mobile number at {company}. Valid for {minutes} minutes. Do not share it with anyone.',
        'sms_tpl_otp_reset'    => '{otp} is your OTP to reset your {company} password. Valid for {minutes} minutes. Do not share it with anyone.',
        'sms_tpl_welcome'      => 'Congratulations {name}! Your {company} account is ready. User ID: {userid}. Referral code: {refcode}. Login at {site}',
    ];
}

function sms_cfg($key = null) {
    static $cache = null;
    if ($cache === null) {
        global $db;
        $cache = sms_defaults();
        try {
            foreach ($db->select("SELECT setting_key, setting_value FROM settings") as $r) {
                if (array_key_exists($r['setting_key'], $cache)) { $cache[$r['setting_key']] = $r['setting_value']; }
            }
        } catch (\Throwable $e) {}
    }
    if ($key === null) return $cache;
    return $cache[$key] ?? (sms_defaults()[$key] ?? '');
}

function sms_cfg_on($key) { return (string)sms_cfg($key) === '1'; }

function sms_cfg_set($key, $value) {
    global $db;
    try {
        $has = $db->select("SELECT id FROM settings WHERE setting_key = ?", [$key]);
        if (!empty($has)) { $db->insert("UPDATE settings SET setting_value = ? WHERE setting_key = ?", [(string)$value, $key]); }
        else { $db->insert("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?)", [$key, (string)$value]); }
    } catch (\Throwable $e) {}
}

/* Is everything the API needs actually filled in? */
function sms_ready() {
    return sms_cfg('sms_username') !== '' && sms_cfg('sms_apikey') !== '' && sms_cfg('sms_sender_id') !== '';
}

/* What is missing, in plain words — shown on the settings screen. */
function sms_missing() {
    $m = [];
    if (sms_cfg('sms_username')  === '') $m[] = 'API username';
    if (sms_cfg('sms_apikey')    === '') $m[] = 'API key';
    if (sms_cfg('sms_sender_id') === '') $m[] = 'Sender ID';
    return $m;
}

/* ---------------------------------------------------------------------------
   Schema, built on demand
   --------------------------------------------------------------------------- */
function sms_ensure_schema($db) {
    static $done = false;
    if ($done) return true;
    $conn = $db->getConnection();
    foreach ([
        "CREATE TABLE IF NOT EXISTS otp_codes (
            id INT AUTO_INCREMENT PRIMARY KEY,
            purpose VARCHAR(30) NOT NULL DEFAULT 'generic',
            mobile VARCHAR(20) NOT NULL,
            email VARCHAR(150) NULL,
            user_id INT NULL,
            otp_hash VARCHAR(255) NOT NULL,
            otp_plain VARCHAR(10) NULL,
            payload MEDIUMTEXT NULL,
            attempts INT NOT NULL DEFAULT 0,
            resend_count INT NOT NULL DEFAULT 0,
            verified TINYINT(1) NOT NULL DEFAULT 0,
            verified_at DATETIME NULL,
            expires_at DATETIME NOT NULL,
            ip_address VARCHAR(45) NULL,
            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
            KEY idx_otp_lookup (purpose, mobile, created_at),
            KEY idx_otp_expiry (expires_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",

        "CREATE TABLE IF NOT EXISTS sms_log (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            mobile VARCHAR(20) NOT NULL,
            message TEXT NULL,
            purpose VARCHAR(30) NULL,
            sender_id VARCHAR(20) NULL,
            status VARCHAR(20) NOT NULL DEFAULT 'sent',
            msg_id VARCHAR(160) NULL,
            response TEXT NULL,
            user_id INT NULL,
            ip_address VARCHAR(45) NULL,
            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
            KEY idx_smslog_mobile (mobile, created_at),
            KEY idx_smslog_status (status),
            KEY idx_smslog_purpose (purpose)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
    ] as $q) { try { $conn->query($q); } catch (\Throwable $e) {} }
    $done = true;
    return true;
}

/* ---------------------------------------------------------------------------
   Small helpers
   --------------------------------------------------------------------------- */

/** Ten digits, whatever the user typed: +91, 0091, spaces, dashes. */
function sms_clean_mobile($m) {
    $d = preg_replace('/\D/', '', (string)$m);
    if (strlen($d) > 10) { $d = substr($d, -10); }
    return $d;
}
function sms_valid_mobile($m) {
    $d = sms_clean_mobile($m);
    return strlen($d) === 10 && $d[0] >= '6';
}

function sms_ip() {
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'] as $k) {
        if (!empty($_SERVER[$k])) {
            $c = trim(explode(',', $_SERVER[$k])[0]);
            if (filter_var($c, FILTER_VALIDATE_IP)) return $c;
        }
    }
    return '0.0.0.0';
}

/** WBMCZ0004 — the code a member is known by. */
function sms_user_code($id) { return 'WBMCZ' . str_pad((int)$id, 4, '0', STR_PAD_LEFT); }

/**
 * Fill a message template.
 * Known holes: {otp} {minutes} {name} {userid} {refcode} {mobile} {company} {site}
 */
function sms_render($template, $vars = []) {
    $base = [
        '{company}' => function_exists('getSetting') ? getSetting('company_name', 'WBMCZ') : 'WBMCZ',
        '{site}'    => defined('SITE_URL') ? SITE_URL : '',
        '{minutes}' => (string)(int)sms_cfg('otp_expiry_minutes'),
    ];
    foreach ($vars as $k => $v) { $base['{' . trim($k, '{}') . '}'] = (string)$v; }
    return strtr((string)$template, $base);
}

/* ---------------------------------------------------------------------------
   THE SEND
   Returns ['ok'=>bool, 'msgid'=>string, 'error'=>string, 'skipped'=>bool]
   Never throws — a text that will not go must never break a page.
   --------------------------------------------------------------------------- */
function sms_send($mobile, $text, $purpose = 'generic', $userId = null) {
    global $db;
    sms_ensure_schema($db);

    $mobile = sms_clean_mobile($mobile);
    $text   = trim((string)$text);
    $out    = ['ok' => false, 'msgid' => '', 'error' => '', 'skipped' => false, 'response' => ''];

    $log = function ($status, $msgid, $response) use ($db, $mobile, $text, $purpose, $userId) {
        try {
            $db->insert(
                "INSERT INTO sms_log (mobile, message, purpose, sender_id, status, msg_id, response, user_id, ip_address)
                 VALUES (?,?,?,?,?,?,?,?,?)",
                [$mobile, $text, $purpose, sms_cfg('sms_sender_id'), $status,
                 substr((string)$msgid, 0, 160), substr((string)$response, 0, 2000),
                 $userId ? (int)$userId : null, sms_ip()]
            );
        } catch (\Throwable $e) {}
    };

    if (!sms_valid_mobile($mobile)) {
        $out['error'] = 'That does not look like a 10 digit Indian mobile number.';
        $log('failed', '', $out['error']);
        return $out;
    }
    if ($text === '') {
        $out['error'] = 'The message was empty.';
        $log('failed', '', $out['error']);
        return $out;
    }

    /* switched off, or being tried out — nothing leaves the building */
    if (!sms_cfg_on('sms_enabled') || sms_cfg_on('sms_test_mode')) {
        $why = !sms_cfg_on('sms_enabled') ? 'SMS sending is switched off' : 'Test mode — nothing was actually sent';
        $out['skipped'] = true; $out['ok'] = true; $out['error'] = $why;
        $log('skipped', '', $why);
        return $out;
    }

    if (!sms_ready()) {
        $out['error'] = 'SMS is not set up yet: ' . implode(', ', sms_missing()) . ' missing.';
        $log('failed', '', $out['error']);
        return $out;
    }

    $url = rtrim(sms_cfg('sms_api_url'), '/') . '/sendSMS?' . http_build_query([
        'username'   => sms_cfg('sms_username'),
        'message'    => $text,
        'sendername' => sms_cfg('sms_sender_id'),
        'smstype'    => sms_cfg('sms_type') ?: 'TRANS',
        'numbers'    => $mobile,
        'apikey'     => sms_cfg('sms_apikey'),
    ]);

    $body = ''; $err = '';
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 20,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_USERAGENT      => 'WBMCZ/1.0',
        ]);
        $body = (string)curl_exec($ch);
        if ($body === '' && curl_errno($ch)) { $err = curl_error($ch); }
        curl_close($ch);
    } else {
        $ctx  = stream_context_create(['http' => ['timeout' => 20, 'ignore_errors' => true]]);
        $body = (string)@file_get_contents($url, false, $ctx);
        if ($body === '') { $err = 'Could not reach the SMS gateway.'; }
    }

    $out['response'] = $body;

    if ($err !== '') {
        $out['error'] = $err;
        $log('failed', '', $err);
        return $out;
    }

    /* ------------------------------------------------------------------
       HSPSMS does not answer in one fixed shape. Two seen in the wild:

         {"ErrorCode":"000","ErrorMessage":"Success","JobId":"123",
          "MessageData":[{"MessageId":"456"}]}

         [{"responseCode":"Message SuccessFully Submitted"},{"msgid":"32014584"}]

       and plain text when something is wrong. So the reply is flattened —
       however deeply it is nested — and then read key by key, so a new
       wrapper around the same fields never turns a delivered message into
       a failure again.
       ------------------------------------------------------------------ */
    $ok = false; $msgid = ''; $code = ''; $note = '';
    $j = json_decode($body, true);

    if (is_array($j)) {
        $flat = [];
        $walk = function ($node) use (&$walk, &$flat) {
            foreach ((array)$node as $k => $v) {
                if (is_array($v)) { $walk($v); }
                elseif (!is_object($v)) { $flat[strtolower((string)$k)] = (string)$v; }
            }
        };
        $walk($j);

        foreach (['msgid', 'messageid', 'msg_id', 'jobid'] as $k) {
            if (!empty($flat[$k])) { $msgid = $flat[$k]; break; }
        }
        foreach (['responsecode', 'errorcode', 'status', 'response'] as $k) {
            if (isset($flat[$k]) && $flat[$k] !== '') { $code = $flat[$k]; break; }
        }
        foreach (['errormessage', 'message', 'description', 'responsecode'] as $k) {
            if (!empty($flat[$k])) { $note = $flat[$k]; break; }
        }

        $lc   = strtolower($code);
        $good = in_array($lc, ['000', '0', 'ok', 'success'], true)
             || strpos($lc, 'success') !== false
             || strpos($lc, 'submit')  !== false;
        $bad  = strpos($lc, 'invalid')      !== false || strpos($lc, 'error') !== false
             || strpos($lc, 'fail')         !== false || strpos($lc, 'insufficient') !== false
             || strpos($lc, 'not found')    !== false || strpos($lc, 'blocked') !== false
             || strpos($lc, 'reject')       !== false || strpos($lc, 'mismatch') !== false;

        $ok = ($good || $msgid !== '') && !$bad;
        if (!$ok) { $out['error'] = $note !== '' ? $note : $body; }
    } else {
        $low = strtolower($body);
        $bad = (strpos($low, 'invalid') !== false || strpos($low, 'error') !== false
             || strpos($low, 'fail')    !== false || strpos($low, 'insufficient') !== false
             || strpos($low, 'not ')    !== false || $body === '');
        $ok  = !$bad;
        if (!$ok) { $out['error'] = $body !== '' ? $body : 'The gateway sent an empty reply.'; }
        if ($ok)  { $msgid = trim($body); }
    }

    $out['ok'] = $ok; $out['msgid'] = $msgid;
    $log($ok ? 'sent' : 'failed', $msgid, $body);
    return $out;
}

/** Credits left on the HSPSMS account. Returns a string, or '' if unknown. */
function sms_balance() {
    if (!sms_ready()) return '';
    $url = rtrim(sms_cfg('sms_api_url'), '/') . '/getSMSCredit?' . http_build_query([
        'username' => sms_cfg('sms_username'),
        'apikey'   => sms_cfg('sms_apikey'),
    ]);
    $body = '';
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false]);
        $body = (string)curl_exec($ch);
        curl_close($ch);
    } else {
        $body = (string)@file_get_contents($url, false, stream_context_create(['http' => ['timeout' => 15, 'ignore_errors' => true]]));
    }
    $j = json_decode($body, true);
    if (is_array($j)) {
        foreach (['Credit', 'credit', 'Balance', 'balance', 'SMSCredit'] as $k) {
            if (isset($j[$k])) return (string)$j[$k];
        }
    }
    return trim(strip_tags($body));
}

/**
 * What the operator actually did with one message.
 *
 * "Message SuccessFully Submitted" only means HSPSMS took it. Whether a phone
 * ever rang is a different question, and this is where it is answered — a DLT
 * template that does not match is accepted at submit time and thrown away by
 * the operator a second later.
 *
 * Returns ['ok'=>bool, 'status'=>string, 'raw'=>string, 'url'=>string]
 */
function sms_dlr($msgid) {
    $msgid = trim((string)$msgid);
    $out = ['ok' => false, 'status' => '', 'raw' => '', 'url' => ''];
    if ($msgid === '' || !sms_ready()) {
        $out['raw'] = 'Give a message id, and make sure the gateway details are filled in.';
        return $out;
    }

    $url = rtrim(sms_cfg('sms_api_url'), '/') . '/getDLR?' . http_build_query([
        'username' => sms_cfg('sms_username'),
        'msgid'    => $msgid,
        'apikey'   => sms_cfg('sms_apikey'),
    ]);
    $out['url'] = preg_replace('/apikey=[^&]*/', 'apikey=***', $url);

    $body = '';
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_SSL_VERIFYPEER => false]);
        $body = (string)curl_exec($ch);
        curl_close($ch);
    } else {
        $body = (string)@file_get_contents($url, false, stream_context_create(['http' => ['timeout' => 20, 'ignore_errors' => true]]));
    }
    $out['raw'] = $body;

    /* pull whatever looks like a status out, whatever the shape */
    $j = json_decode($body, true);
    if (is_array($j)) {
        $flat = [];
        $walk = function ($n) use (&$walk, &$flat) {
            foreach ((array)$n as $k => $v) {
                if (is_array($v)) { $walk($v); }
                elseif (!is_object($v)) { $flat[strtolower((string)$k)] = (string)$v; }
            }
        };
        $walk($j);
        foreach (['deliverystatus', 'status', 'dlrstatus', 'responsecode', 'description'] as $k) {
            if (!empty($flat[$k])) { $out['status'] = $flat[$k]; break; }
        }
    } else {
        $out['status'] = trim(strip_tags($body));
    }

    $lc = strtolower($out['status']);
    $out['ok'] = (strpos($lc, 'deliver') !== false || $lc === 'dlvrd' || $lc === 'success');
    return $out;
}

/* ---------------------------------------------------------------------------
   ONE-TIME PASSWORDS
   --------------------------------------------------------------------------- */

/** Which message template belongs to which purpose. */
function otp_template($purpose) {
    $map = ['register' => 'sms_tpl_otp_register', 'reset' => 'sms_tpl_otp_reset'];
    return sms_cfg($map[$purpose] ?? 'sms_tpl_otp_register');
}

/**
 * The newest code for this number that is still alive and not yet used.
 * Every comparison with the clock happens inside MySQL, so it stays right even
 * when PHP is set to Asia/Kolkata and the database server is running on UTC.
 */
function otp_active($purpose, $mobile) {
    global $db;
    sms_ensure_schema($db);
    try {
        $r = $db->select(
            "SELECT *, GREATEST(0, TIMESTAMPDIFF(SECOND, NOW(), expires_at)) AS secs_left
             FROM otp_codes
             WHERE purpose = ? AND mobile = ? AND verified = 0 AND expires_at > NOW()
             ORDER BY id DESC LIMIT 1",
            [$purpose, sms_clean_mobile($mobile)]);
        return !empty($r) ? $r[0] : null;
    } catch (\Throwable $e) { return null; }
}

/** Seconds still to wait before another code may be asked for. */
function otp_cooldown_left($purpose, $mobile) {
    global $db;
    $gap = max(0, (int)sms_cfg('otp_resend_seconds'));
    if ($gap === 0) return 0;
    try {
        $r = $db->select(
            "SELECT GREATEST(0, ? - TIMESTAMPDIFF(SECOND, created_at, NOW())) AS wait
             FROM otp_codes WHERE purpose = ? AND mobile = ? ORDER BY id DESC LIMIT 1",
            [$gap, $purpose, sms_clean_mobile($mobile)]);
        return !empty($r) ? (int)$r[0]['wait'] : 0;
    } catch (\Throwable $e) { return 0; }
}

/** How many codes this number has already had today. */
function otp_today_count($mobile) {
    global $db;
    try {
        $r = $db->select(
            "SELECT COUNT(*) c FROM otp_codes WHERE mobile = ? AND created_at >= CURDATE()",
            [sms_clean_mobile($mobile)]);
        return (int)($r[0]['c'] ?? 0);
    } catch (\Throwable $e) { return 0; }
}

/**
 * Make a code, text it, and remember it.
 *
 * @param string $purpose  register | reset | login | generic
 * @param string $mobile
 * @param array  $vars     extra holes for the template: name, userid …
 * @param array  $payload  anything to hold until the code is confirmed
 *                         (the pending registration, for instance)
 * @return array ['ok','id','error','wait','test_code','skipped']
 */
function otp_send($purpose, $mobile, $vars = [], $payload = null, $userId = null, $email = null) {
    global $db;
    sms_ensure_schema($db);

    $mobile = sms_clean_mobile($mobile);
    $out = ['ok' => false, 'id' => 0, 'error' => '', 'wait' => 0, 'test_code' => '', 'skipped' => false];

    if (!sms_valid_mobile($mobile)) {
        $out['error'] = 'Please give a correct 10 digit mobile number.';
        return $out;
    }

    $wait = otp_cooldown_left($purpose, $mobile);
    if ($wait > 0) {
        $out['error'] = "Please wait {$wait} more second(s) before asking for another code.";
        $out['wait']  = $wait;
        return $out;
    }

    $cap = (int)sms_cfg('otp_max_per_day');
    if ($cap > 0 && otp_today_count($mobile) >= $cap) {
        $out['error'] = 'This number has had too many codes today. Please try again tomorrow.';
        return $out;
    }

    $len  = max(4, min(8, (int)sms_cfg('otp_length')));
    $code = '';
    for ($i = 0; $i < $len; $i++) { $code .= random_int(0, 9); }
    /* never start with a zero — some phones hide it */
    if ($code[0] === '0') { $code[0] = (string)random_int(1, 9); }

    $mins = max(1, (int)sms_cfg('otp_expiry_minutes'));
    $test = sms_cfg_on('sms_test_mode');

    try {
        $id = $db->insert(
            "INSERT INTO otp_codes (purpose, mobile, email, user_id, otp_hash, otp_plain, payload, expires_at, ip_address)
             VALUES (?,?,?,?,?,?,?, DATE_ADD(NOW(), INTERVAL $mins MINUTE), ?)",
            [$purpose, $mobile, $email, $userId ? (int)$userId : null,
             password_hash($code, PASSWORD_DEFAULT),
             $test ? $code : null,
             $payload !== null ? json_encode($payload, JSON_UNESCAPED_UNICODE) : null,
             sms_ip()]
        );
    } catch (\Throwable $e) { $id = 0; }

    if (!$id) {
        $out['error'] = 'The code could not be saved. Please try again.';
        return $out;
    }

    $text = sms_render(otp_template($purpose), array_merge($vars, ['otp' => $code, 'mobile' => $mobile]));
    $res  = sms_send($mobile, $text, 'otp_' . $purpose, $userId);

    $out['id']      = (int)$id;
    $out['ok']      = $res['ok'];
    $out['skipped'] = $res['skipped'];
    if ($test)        { $out['test_code'] = $code; }
    if (!$res['ok'])  { $out['error'] = $res['error'] ?: 'The message could not be sent.'; }

    return $out;
}

/**
 * Is that the right code?
 * @return array ['ok','error','row']
 */
function otp_check($purpose, $mobile, $code) {
    global $db;
    sms_ensure_schema($db);

    $mobile = sms_clean_mobile($mobile);
    $code   = preg_replace('/\D/', '', (string)$code);
    $out    = ['ok' => false, 'error' => '', 'row' => null];

    if ($code === '') { $out['error'] = 'Please type the code you received.'; return $out; }

    $row = otp_active($purpose, $mobile);
    if (!$row) {
        $out['error'] = 'That code has expired. Please ask for a new one.';
        return $out;
    }

    $max = max(1, (int)sms_cfg('otp_max_attempts'));
    if ((int)$row['attempts'] >= $max) {
        $out['error'] = 'Too many wrong tries. Please ask for a new code.';
        try { $db->insert("UPDATE otp_codes SET expires_at = NOW() WHERE id = ?", [(int)$row['id']]); } catch (\Throwable $e) {}
        return $out;
    }

    if (!password_verify($code, $row['otp_hash'])) {
        try { $db->insert("UPDATE otp_codes SET attempts = attempts + 1 WHERE id = ?", [(int)$row['id']]); } catch (\Throwable $e) {}
        $left = $max - ((int)$row['attempts'] + 1);
        $out['error'] = $left > 0
            ? "That code is not right. $left try(s) left."
            : 'That code is not right, and there are no tries left. Please ask for a new one.';
        return $out;
    }

    try {
        $db->insert("UPDATE otp_codes SET verified = 1, verified_at = NOW() WHERE id = ?", [(int)$row['id']]);
    } catch (\Throwable $e) {}

    $out['ok']  = true;
    $out['row'] = $row;
    return $out;
}

/** What the OTP screen needs: how long left, how many tries, cooldown. */
function otp_state($purpose, $mobile) {
    $row = otp_active($purpose, $mobile);
    return [
        'exists'    => (bool)$row,
        'seconds'   => $row ? (int)($row['secs_left'] ?? 0) : 0,
        'attempts'  => $row ? (int)$row['attempts'] : 0,
        'max'       => max(1, (int)sms_cfg('otp_max_attempts')),
        'cooldown'  => otp_cooldown_left($purpose, $mobile),
        'test_code' => ($row && sms_cfg_on('sms_test_mode')) ? (string)$row['otp_plain'] : '',
    ];
}

/** Whatever was parked with the code, back as an array. */
function otp_payload($row) {
    if (empty($row['payload'])) return [];
    $d = json_decode((string)$row['payload'], true);
    return is_array($d) ? $d : [];
}

/** Throw away everything for this number and purpose — used once a job is done. */
function otp_clear($purpose, $mobile) {
    global $db;
    try { $db->insert("DELETE FROM otp_codes WHERE purpose = ? AND mobile = ?", [$purpose, sms_clean_mobile($mobile)]); }
    catch (\Throwable $e) {}
}

/**
 * The congratulations text a new member gets.
 * Sent after the account really exists, so the user ID inside it is real.
 */
function sms_send_welcome($userId, $name, $mobile) {
    if (!sms_cfg_on('sms_welcome_enabled')) return ['ok' => false, 'skipped' => true, 'error' => 'Welcome SMS is switched off'];
    $text = sms_render(sms_cfg('sms_tpl_welcome'), [
        'name'    => $name,
        'userid'  => sms_user_code($userId),
        'refcode' => sms_user_code($userId),
        'mobile'  => sms_clean_mobile($mobile),
    ]);
    return sms_send($mobile, $text, 'welcome', $userId);
}

} // end function_exists guard
