<?php
/**
 * Auto-extracted module from the old large bot.php.
 * Loaded by bot.php in the original execution order.
 */
function bale_api_error_means_left($description){
    $description = strtolower((string)$description);
    if($description === '') return false;
    $patterns = [
        'user_not_participant',
        'participant_id_invalid',
        'user not participant',
        'not a participant',
        'not participant',
        'member not found',
        'chat member not found',
        'participant not found',
        'user not found',
        'not found in chat',
        'کاربر یافت نشد',
        'عضو نیست',
    ];
    foreach($patterns as $pattern){
        if(strpos($description, $pattern) !== false) return true;
    }
    return false;
}


function bale_api_error_is_no_such_group_or_user($description){
    $description = strtolower((string)$description);
    return strpos($description, 'no such group or user') !== false;
}

function bale_bot_admin_confirmed_for_left_check($channelUsername){
    $check = bale_bot_channel_status($channelUsername);
    return !empty($check['admin']);
}

function bale_api_error_is_uncertain($description){
    $description = strtolower((string)$description);
    if($description === '') return true;
    $patterns = [
        'chat not found',
        'channel not found',
        'bot is not',
        'not enough rights',
        'forbidden',
        'unauthorized',
        'invalid token',
        'admin',
        'administrator',
        'ادمین',
        'دسترسی',
        'کانال یافت نشد',
    ];
    foreach($patterns as $pattern){
        if(strpos($description, $pattern) !== false) return true;
    }
    return false;
}

function bale_get_member_status_for_left_check($channelUsername, $userId){
    $channelUsername = normalize_bale_channel_username($channelUsername);
    if($channelUsername === '' || empty($userId)) return null;
    if(function_exists('bale_channel_failure_in_cooldown') && bale_channel_failure_in_cooldown($channelUsername)){
        return null;
    }
    foreach(bale_chat_id_candidates_for_channel($channelUsername) as $chatId){
        $res = bot('getChatMember', [
            'chat_id' => $chatId,
            'user_id' => $userId,
        ]);
        if(is_object($res) && isset($res->result) && isset($res->result->status)){
            return (string)$res->result->status;
        }
        $desc = (is_object($res) && isset($res->description)) ? (string)$res->description : '';
        if(bale_api_error_means_left($desc)){
            return 'left';
        }
        // Telegram can return 404/no such group or user for a user who left the channel.
        // Count it as LEFT only when the bot admin check for this channel is already confirmed.
        if(bale_api_error_is_no_such_group_or_user($desc) && bale_bot_admin_confirmed_for_left_check($channelUsername)){
            return 'left';
        }
        if(bale_api_error_is_uncertain($desc)){
            if(function_exists('bale_channel_failure_should_cooldown') && bale_channel_failure_should_cooldown($desc) && function_exists('bale_channel_failure_note')){
                bale_channel_failure_note($channelUsername, $desc);
            }
            return null;
        }
    }
    return null;
}

function bale_is_left_status($status){
    return in_array((string)$status, ['left','kicked'], true);
}

function bale_left_settings(){
    global $data_left;
    if(!isset($data_left) || !is_array($data_left)){
        $data_left = bot_data_get_json('lib/kodam/data-left.json');
        if(!is_array($data_left)) $data_left = [];
    }
    $defaults = [
        'enabled'=>'✅فعال',
        'leftday'=>'3',
        'ksrleft'=>'2',
        'afzleft'=>'1',
        'max_checks'=>'20',
        'instant_max_checks'=>'500',
        'instant_max_seconds'=>'25',
        'grace_minutes'=>'30',
        'confirm_attempts'=>'2',
        'notify_user'=>'✅فعال',
        'notify_owner'=>'✅فعال',
        'auto_penalty'=>'✅فعال',
        'event_auto_penalty'=>'✅فعال',
        // بررسی لفت روی هر پیام کاربر به‌صورت پیش‌فرض خاموش است؛ مسیر اصلی اتوماتیک همان chat_member webhook است.
        // روشن بودن این گزینه روی کانال‌های مشکل‌دار باعث فشار و getChatMember زیاد می‌شود.
        'interaction_auto_penalty'=>'❌غیر فعال',
        'event_max_orders'=>'50',
    ];
    return array_merge($defaults, $data_left);
}

function bale_left_enabled(){
    $s = bale_left_settings();
    return !isset($s['enabled']) || (string)$s['enabled'] !== '❌غیر فعال';
}

function bale_left_auto_penalty_enabled(){
    $s = bale_left_settings();
    return !isset($s['auto_penalty']) || (string)$s['auto_penalty'] !== '❌غیر فعال';
}

function bale_left_event_auto_enabled(){
    $s = bale_left_settings();
    return !isset($s['event_auto_penalty']) || (string)$s['event_auto_penalty'] !== '❌غیر فعال';
}

function bale_left_interaction_auto_enabled(){
    $s = bale_left_settings();
    return isset($s['interaction_auto_penalty']) && (string)$s['interaction_auto_penalty'] === '✅فعال';
}

function bale_left_event_max_orders(){
    return bale_left_int_setting('event_max_orders', 50, 1, 500);
}

function bale_left_bool_setting($key, $default=true){
    $s = bale_left_settings();
    if(!array_key_exists($key, $s)) return $default;
    return (string)$s[$key] !== '❌غیر فعال';
}

function bale_left_int_setting($key, $default=0, $min=0, $max=100000){
    $s = bale_left_settings();
    $v = isset($s[$key]) ? (int)$s[$key] : (int)$default;
    if($v < $min) $v = $min;
    if($v > $max) $v = $max;
    return $v;
}

function bale_left_member_join_ts($order, $userId){
    $userId = (string)$userId;
    if(isset($order['joined_log']) && is_array($order['joined_log']) && isset($order['joined_log'][$userId])) return (int)$order['joined_log'][$userId];
    if(isset($order['claim_started']) && is_array($order['claim_started']) && isset($order['claim_started'][$userId])) return (int)$order['claim_started'][$userId];
    return 0;
}

function bale_left_in_grace_period($order, $userId){
    $minutes = bale_left_int_setting('grace_minutes', 30, 0, 1440);
    if($minutes <= 0) return false;
    $ts = bale_left_member_join_ts($order, $userId);
    if($ts <= 0) return false;
    return (time() - $ts) < ($minutes * 60);
}

function bale_left_note_uncertain(&$order, $userId, $status='uncertain'){
    if(!isset($order['left_uncertain_log']) || !is_array($order['left_uncertain_log'])) $order['left_uncertain_log'] = [];
    $userId = (string)$userId;
    $old = isset($order['left_uncertain_log'][$userId]) && is_array($order['left_uncertain_log'][$userId]) ? $order['left_uncertain_log'][$userId] : [];
    $old['count'] = (int)($old['count'] ?? 0) + 1;
    $old['last_time'] = time();
    $old['status'] = (string)$status;
    $order['left_uncertain_log'][$userId] = $old;
}

function bale_left_confirm_or_penalize($userId, $orderId, &$order, $notify=true){
    $attempts = bale_left_int_setting('confirm_attempts', 2, 1, 5);
    if(!isset($order['left_candidates']) || !is_array($order['left_candidates'])) $order['left_candidates'] = [];
    $userId = (string)$userId;
    $row = isset($order['left_candidates'][$userId]) && is_array($order['left_candidates'][$userId]) ? $order['left_candidates'][$userId] : ['count'=>0];
    $row['count'] = (int)($row['count'] ?? 0) + 1;
    $row['last_time'] = time();
    $row['order_id'] = (string)$orderId;
    $order['left_candidates'][$userId] = $row;
    if($row['count'] < $attempts){
        saveJson("ads/{$orderId}.json", $order);
        return ['penalized'=>false, 'candidate'=>true, 'attempts'=>(int)$row['count'], 'needed'=>$attempts];
    }
    unset($order['left_candidates'][$userId]);
    $ok = bale_apply_left_penalty($userId, $orderId, $order, $notify);
    return ['penalized'=>(bool)$ok, 'candidate'=>false, 'attempts'=>(int)$row['count'], 'needed'=>$attempts];
}

function bale_left_sql_pdo(){
    return function_exists('bot_sql_pdo') ? bot_sql_pdo() : null;
}

function bale_left_sql_order_row($orderId){
    $pdo = bale_left_sql_pdo();
    if(!$pdo) return null;
    try{
        $st = $pdo->prepare('SELECT order_id, channel_username, channel_chat_id, data_json FROM bot_orders WHERE order_id=? LIMIT 1');
        $st->execute([(string)$orderId]);
        $row = $st->fetch();
        return is_array($row) ? $row : null;
    }catch(Throwable $e){
        error_log('left_check order row failed: '.$e->getMessage());
        return null;
    }
}

function bale_left_sql_order_members($orderId){
    $pdo = bale_left_sql_pdo();
    if(!$pdo) return [];
    try{
        $st = $pdo->prepare('SELECT user_id FROM bot_order_members WHERE order_id=? AND (penalized=0 OR penalized IS NULL) ORDER BY joined_ts ASC, user_id ASC');
        $st->execute([(string)$orderId]);
        $ids = $st->fetchAll(PDO::FETCH_COLUMN);
        return is_array($ids) ? array_values(array_filter(array_map('strval', $ids), function($v){ return $v !== '' && $v !== '0'; })) : [];
    }catch(Throwable $e){
        error_log('left_check order members failed: '.$e->getMessage());
        return [];
    }
}

function bale_left_sql_penalized_members($orderId){
    $pdo = bale_left_sql_pdo();
    if(!$pdo) return [];
    try{
        $st = $pdo->prepare('SELECT user_id FROM bot_order_members WHERE order_id=? AND (penalized=1 OR left_ts IS NOT NULL)');
        $st->execute([(string)$orderId]);
        $ids = $st->fetchAll(PDO::FETCH_COLUMN);
        return is_array($ids) ? array_values(array_filter(array_map('strval', $ids), function($v){ return $v !== '' && $v !== '0'; })) : [];
    }catch(Throwable $e){ return []; }
}

function bale_left_merge_unique_ids($a, $b){
    $out = [];
    foreach([(array)$a, (array)$b] as $list){
        foreach($list as $id){
            $id = (string)$id;
            if($id === '' || $id === '0') continue;
            $out[$id] = $id;
        }
    }
    return array_values($out);
}

function bale_hydrate_order_for_left_check($orderId, &$order){
    if(!is_array($order)) $order = [];
    bale_normalize_order_arrays($order);

    $row = bale_left_sql_order_row($orderId);
    if($row){
        if((empty($order['channel']) || trim((string)$order['channel']) === '') && !empty($row['channel_username'])){
            $order['channel'] = (string)$row['channel_username'];
        }
        if((empty($order['channel_id']) || trim((string)$order['channel_id']) === '') && !empty($row['channel_chat_id'])){
            $order['channel_id'] = (string)$row['channel_chat_id'];
        }
        $fromJson = json_decode((string)($row['data_json'] ?? ''), true);
        if(is_array($fromJson)){
            if((empty($order['channel']) || trim((string)$order['channel']) === '') && !empty($fromJson['channel'])) $order['channel'] = (string)$fromJson['channel'];
            if((empty($order['admin']) || trim((string)$order['admin']) === '') && !empty($fromJson['admin'])) $order['admin'] = (string)$fromJson['admin'];
            if((empty($order['stamp']) || (int)$order['stamp'] <= 0) && !empty($fromJson['stamp'])) $order['stamp'] = (int)$fromJson['stamp'];
        }
    }

    $sqlMembers = bale_left_sql_order_members($orderId);
    $order['members'] = bale_left_merge_unique_ids($order['members'] ?? [], $sqlMembers);
    $sqlLeft = bale_left_sql_penalized_members($orderId);
    $order['left'] = bale_left_merge_unique_ids($order['left'] ?? [], $sqlLeft);
    return $order;
}

function bale_left_member_exists($userId){
    $userId = (string)$userId;
    if($userId === '' || $userId === '0') return false;
    if(bot_data_file_exists("melat/{$userId}.json")) return true;
    $pdo = bale_left_sql_pdo();
    if(!$pdo) return false;
    try{
        $st = $pdo->prepare('SELECT 1 FROM bot_users WHERE user_id=? LIMIT 1');
        $st->execute([$userId]);
        return (bool)$st->fetchColumn();
    }catch(Throwable $e){ return false; }
}


function bale_normalize_order_arrays(&$order){
    if(!isset($order['members']) || !is_array($order['members'])) $order['members'] = [];
    if(!isset($order['left']) || !is_array($order['left'])) $order['left'] = [];
    if(!isset($order['Report']) || !is_array($order['Report'])) $order['Report'] = [];
    // Users must first press the reward button while they are NOT a member.
    // This prevents old channel members from receiving reward for an order.
    if(!isset($order['claim_started']) || !is_array($order['claim_started'])) $order['claim_started'] = [];
    if(!isset($order['pre_members']) || !is_array($order['pre_members'])) $order['pre_members'] = [];
    if(!isset($order['pre_members_log']) || !is_array($order['pre_members_log'])) $order['pre_members_log'] = [];
    if(!isset($order['joined_log']) || !is_array($order['joined_log'])) $order['joined_log'] = [];
    if(!isset($order['left_candidates']) || !is_array($order['left_candidates'])) $order['left_candidates'] = [];
    if(!isset($order['left_uncertain_log']) || !is_array($order['left_uncertain_log'])) $order['left_uncertain_log'] = [];
}

function bale_order_has_claim_start($order, $userId){
    $userId = (string)$userId;
    if(!isset($order['claim_started']) || !is_array($order['claim_started'])) return false;
    return array_key_exists($userId, $order['claim_started']) || bale_member_id_in_array($userId, $order['claim_started']);
}

function bale_order_mark_claim_start(&$order, $userId){
    bale_normalize_order_arrays($order);
    $userId = (string)$userId;
    if($userId === '') return;
    if(!array_key_exists($userId, $order['claim_started'])){
        $order['claim_started'][$userId] = time();
    }
}

function bale_order_mark_pre_member(&$order, $userId, $status = ''){
    bale_normalize_order_arrays($order);
    $userId = (string)$userId;
    if($userId === '') return;
    if(!bale_member_id_in_array($userId, $order['pre_members'])){
        $order['pre_members'][] = $userId;
    }
    $order['pre_members_log'][$userId] = [
        'time' => time(),
        'status' => (string)$status,
    ];
}

function bale_left_escape($text){
    return htmlspecialchars((string)$text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function bale_apply_left_penalty($userId, $orderId, &$order, $notify = true){
    global $data_left, $money;
    $userId = (string)$userId;
    $memberFile = "melat/{$userId}.json";
    if($userId === '' || !bale_left_member_exists($userId)) return false;

    bale_hydrate_order_for_left_check($orderId, $order);
    if(!bale_member_id_in_array($userId, $order['members'])) return false;
    if(bale_member_id_in_array($userId, $order['left'])) return false;

    if(!bale_left_auto_penalty_enabled()) return false;
    $ksrleft = bale_left_int_setting('ksrleft', 2, 0, 100000);
    $afzleft = bale_left_int_setting('afzleft', 1, 0, 100000);

    $member = bot_data_get_json($memberFile);
    if(!is_array($member)) $member = [];
    $oldPoints = isset($member['Points']) ? (int)$member['Points'] : 0;
    if(function_exists('bot_wallet_add') && $ksrleft > 0){
        $wallet = bot_wallet_add($userId, -$ksrleft, 'left_penalty', ['order_id'=>(string)$orderId, 'channel'=>($order['channel'] ?? '')]);
        if(is_array($wallet) && !empty($wallet['ok'])){
            $newPoints = (int)($wallet['after'] ?? max(0, $oldPoints - $ksrleft));
            $member = is_array($wallet['data'] ?? null) ? $wallet['data'] : bot_data_get_json($memberFile);
            if(!is_array($member)) $member = [];
            $member['Points'] = $newPoints;
        }else{
            $newPoints = max(0, $oldPoints - $ksrleft);
            $member['Points'] = $newPoints;
            saveJson($memberFile, $member);
        }
    }else{
        $newPoints = max(0, $oldPoints - $ksrleft);
        $member['Points'] = $newPoints;
        saveJson($memberFile, $member);
    }

    $order['left'][] = $userId;
    if(!isset($order['left_log']) || !is_array($order['left_log'])) $order['left_log'] = [];
    $order['left_log'][$userId] = [
        'time' => time(),
        'channel' => isset($order['channel']) ? normalize_bale_channel_username($order['channel']) : '',
        'cut' => $ksrleft,
        'before' => $oldPoints,
        'after' => $newPoints,
        'by' => 'bot_or_cron'
    ];
    saveJson("ads/{$orderId}.json", $order);

    $adminId = isset($order['admin']) ? (string)$order['admin'] : '';
    if($adminId !== '' && bot_data_file_exists("melat/{$adminId}.json") && $afzleft > 0){
        if(function_exists('bot_wallet_add')){
            bot_wallet_add($adminId, $afzleft, 'left_return', ['order_id'=>(string)$orderId, 'member_id'=>$userId, 'channel'=>($order['channel'] ?? '')]);
        }else{
            $adminData = bot_data_get_json("melat/{$adminId}.json");
            if(!is_array($adminData)) $adminData = [];
            $adminData['Points'] = (isset($adminData['Points']) ? (int)$adminData['Points'] : 0) + $afzleft;
            saveJson("melat/{$adminId}.json", $adminData);
        }
    }

    if($notify){
        $channel = isset($order['channel']) ? normalize_bale_channel_username($order['channel']) : '';
        $channelText = $channel !== '' ? '@'.$channel : 'کانال سفارش';
        $safeChannel = bale_left_escape($channelText);
        $safeMoney = bale_left_escape($money);
        $safeOrder = bale_left_escape($orderId);

        if(bale_left_bool_setting('notify_user', true)) SM($userId,
            "❗️ **کسر {$safeMoney} بابت ترک کانال**\n\n".
            "شما از کانال {$safeChannel} خارج شدید. به همین دلیل مقدار **{$ksrleft} {$safeMoney}** از حساب شما کسر شد.\n".
            "کد سفارش: <code>{$safeOrder}</code>\n".
            "موجودی فعلی: **{$newPoints} {$safeMoney}**",
            'html'
        );

        if(bale_left_bool_setting('notify_owner', true) && $adminId !== '' && $afzleft > 0){
            SM($adminId,
                "✅ کاربر <code>".bale_left_escape($userId)."</code> از کانال {$safeChannel} خارج شد.\n".
                "مقدار **{$afzleft} {$safeMoney}** به حساب شما برگشت داده شد.\n".
                "کد سفارش: <code>{$safeOrder}</code>",
                'html'
            );
        }
    }
    return true;
}

function bale_check_left_for_user_orders($userId, $notify = true, $maxChecks = 0){
    global $data_left;
    $userId = (string)$userId;
    if($userId === '' || !bot_data_file_exists("melat/{$userId}.json")) return ['checked'=>0,'penalized'=>0];

    if(!bale_left_enabled()) return ['checked'=>0,'penalized'=>0,'disabled'=>true];
    $days = bale_left_int_setting('leftday', 3, 1, 365);
    $cutoff = strtotime(date('Y-m-d', strtotime("-{$days} day")));
    $checked = 0;
    $penalized = 0;

    foreach(bot_data_glob('ads/*.json') as $adsFile){
        $orderId = basename($adsFile, '.json');
        $order = bot_data_get_json($adsFile);
        if(!is_array($order)) continue;
        bale_hydrate_order_for_left_check($orderId, $order);

        $stamp = isset($order['stamp']) ? (int)$order['stamp'] : 0;
        if($stamp > 0 && $stamp <= $cutoff) continue;
        if(!isset($order['channel']) || trim((string)$order['channel']) === '') continue;
        if(!bale_member_id_in_array($userId, $order['members'])) continue;
        if(bale_member_id_in_array($userId, $order['left'])) continue;

        $status = bale_get_member_status_for_left_check($order['channel'], $userId);
        $checked++;
        if(bale_is_left_status($status)){
            if(!bale_left_in_grace_period($order, $userId)){
                $res = bale_left_confirm_or_penalize($userId, $orderId, $order, $notify);
                if(!empty($res['penalized'])) $penalized++;
            }
        }elseif($status === null){
            bale_left_note_uncertain($order, $userId, 'api_uncertain');
            saveJson("ads/{$orderId}.json", $order);
        }
        if($maxChecks > 0 && $checked >= $maxChecks) break;
    }
    return ['checked'=>$checked,'penalized'=>$penalized];
}


function bale_left_normalize_channel_keys($values){
    $keys = [];
    foreach((array)$values as $value){
        $value = trim((string)$value);
        if($value === '') continue;
        $value = str_replace(["\r", "\n", "\t", ' '], '', $value);
        $value = preg_replace('~^(https?://)?(www\.)?(t\.me)/~i', '', $value);
        if(strpos($value, '?') !== false) $value = explode('?', $value, 2)[0];
        $value = trim($value, "/ ");
        $clean = function_exists('normalize_bale_channel_username') ? normalize_bale_channel_username($value) : trim($value, '@/ ');
        if($clean === '') continue;
        if(function_exists('is_bale_numeric_chat_id') && is_bale_numeric_chat_id($clean)){
            $keys['id:'.$clean] = true;
        }elseif(preg_match('/^-?\d{5,30}$/', $clean)){
            $keys['id:'.$clean] = true;
        }else{
            $keys['u:'.strtolower($clean)] = true;
        }
    }
    return $keys;
}

function bale_left_chat_event_refs($chat){
    $refs = ['id'=>'', 'username'=>'', 'title'=>'', 'keys'=>[]];
    if(!is_object($chat)) return $refs;
    $id = isset($chat->id) ? (string)$chat->id : '';
    $username = isset($chat->username) ? (function_exists('normalize_bale_channel_username') ? normalize_bale_channel_username($chat->username) : trim((string)$chat->username, '@/ ')) : '';
    $title = isset($chat->title) ? (string)$chat->title : '';
    if($id !== '' && function_exists('bale_save_channel_map_entry')){
        bale_save_channel_map_entry($username, $id, $title);
    }elseif(function_exists('bale_chat_object_to_ref')){
        bale_chat_object_to_ref($chat);
    }
    $refs['id'] = $id;
    $refs['username'] = $username;
    $refs['title'] = $title;
    $refs['keys'] = bale_left_normalize_channel_keys([$id, $username]);
    return $refs;
}

function bale_left_order_matches_chat_event($order, $chatRefs){
    if(!is_array($order) || !is_array($chatRefs)) return false;
    $values = [];
    foreach(['channel','channel_id','channel_chat_id','chat_id','target_chat_id'] as $key){
        if(isset($order[$key]) && trim((string)$order[$key]) !== '') $values[] = (string)$order[$key];
    }
    if(isset($order['chat']) && is_array($order['chat'])){
        foreach(['id','username'] as $key){ if(!empty($order['chat'][$key])) $values[] = (string)$order['chat'][$key]; }
    }
    $orderKeys = bale_left_normalize_channel_keys($values);
    if(empty($orderKeys) || empty($chatRefs['keys']) || !is_array($chatRefs['keys'])) return false;
    foreach($orderKeys as $key=>$v){
        if(isset($chatRefs['keys'][$key])) return true;
    }
    return false;
}

function bale_left_event_log($row){
    if(!is_array($row)) $row = ['message'=>(string)$row];
    $row['time'] = time();
    $path = 'cache/left_event_log.json';
    $log = function_exists('bot_data_get_json') ? bot_data_get_json($path, []) : [];
    if(!is_array($log)) $log = [];
    $log[] = $row;
    if(count($log) > 200) $log = array_slice($log, -200);
    if(function_exists('bot_data_put_json')) bot_data_put_json($path, $log);
    else @file_put_contents($path, json_encode($log, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT), LOCK_EX);
}

function bale_penalize_left_member_from_chat_event($userId, $chat, $notify = true, $source = 'chat_member'){
    $userId = (string)$userId;
    $result = ['checked'=>0, 'matched_orders'=>0, 'penalized'=>0, 'skipped_grace'=>0, 'already_penalized'=>0, 'errors'=>0, 'source'=>(string)$source];
    if($userId === '' || $userId === '0') return $result + ['error'=>'empty_user'];
    if(!bale_left_enabled()) return $result + ['disabled'=>true];
    if(!bale_left_event_auto_enabled()) return $result + ['event_auto_disabled'=>true];
    if(!bale_left_member_exists($userId)) return $result + ['user_not_found'=>true];

    $chatRefs = bale_left_chat_event_refs($chat);
    if(empty($chatRefs['keys'])) return $result + ['error'=>'empty_chat'];

    $days = bale_left_int_setting('leftday', 3, 1, 365);
    $cutoff = strtotime(date('Y-m-d', strtotime("-{$days} day")));
    $limit = bale_left_event_max_orders();

    foreach(bot_data_glob('ads/*.json') as $adsFile){
        if($result['checked'] >= $limit) break;
        $orderId = basename($adsFile, '.json');
        $order = bot_data_get_json($adsFile);
        if(!is_array($order)) continue;
        bale_hydrate_order_for_left_check($orderId, $order);
        if(!bale_left_order_is_checkable($order, $cutoff)) continue;
        if(!bale_left_order_matches_chat_event($order, $chatRefs)) continue;

        $result['checked']++;
        if(!bale_member_id_in_array($userId, $order['members'])) continue;
        $result['matched_orders']++;
        if(bale_member_id_in_array($userId, $order['left'])){
            $result['already_penalized']++;
            continue;
        }
        // آپدیت chat_member خود تلگرام/بله رخداد قطعی خروج است؛ برای فشار کمتر و جریمه درجا، زمان امان و تأیید مرحله‌ای را دور می‌زنیم.
        $ok = bale_apply_left_penalty($userId, $orderId, $order, $notify);
        if($ok) $result['penalized']++;
        else $result['errors']++;
    }

    $result['chat_id'] = (string)($chatRefs['id'] ?? '');
    $result['chat_username'] = (string)($chatRefs['username'] ?? '');
    bale_left_event_log($result + ['user_id'=>$userId]);
    return $result;
}

function bale_left_extract_changed_member_user_id($memberObj){
    if(!is_object($memberObj)) return '';
    if(isset($memberObj->user) && is_object($memberObj->user) && isset($memberObj->user->id)) return (string)$memberObj->user->id;
    return '';
}

function bale_left_member_update_status($memberObj){
    return (is_object($memberObj) && isset($memberObj->status)) ? (string)$memberObj->status : '';
}

function bale_handle_left_chat_member_update($update){
    if(!is_object($update)) return false;

    // Telegram/Bale chat_member update: بهترین حالت برای جریمه درجا و بدون اسکن سنگین.
    if(isset($update->chat_member) && is_object($update->chat_member)){
        $ev = $update->chat_member;
        $chat = isset($ev->chat) && is_object($ev->chat) ? $ev->chat : null;
        $oldStatus = bale_left_member_update_status($ev->old_chat_member ?? null);
        $newStatus = bale_left_member_update_status($ev->new_chat_member ?? null);
        $leftUserId = bale_left_extract_changed_member_user_id($ev->new_chat_member ?? null);
        if($leftUserId === '') $leftUserId = bale_left_extract_changed_member_user_id($ev->old_chat_member ?? null);
        if($chat && $leftUserId !== '' && bale_is_left_status($newStatus) && !bale_is_left_status($oldStatus)){
            bale_penalize_left_member_from_chat_event($leftUserId, $chat, true, 'chat_member');
        }
        return true;
    }

    // پیام‌های گروهی left_chat_member را هم هندل می‌کنیم؛ برای کانال معمولاً chat_member لازم است.
    if(isset($update->message) && is_object($update->message) && isset($update->message->left_chat_member) && is_object($update->message->left_chat_member)){
        $msg = $update->message;
        $chat = isset($msg->chat) && is_object($msg->chat) ? $msg->chat : null;
        $leftUserId = isset($msg->left_chat_member->id) ? (string)$msg->left_chat_member->id : '';
        if($chat && $leftUserId !== ''){
            bale_penalize_left_member_from_chat_event($leftUserId, $chat, true, 'message_left_chat_member');
            return true;
        }
    }

    return false;
}

function bale_left_cursor_file(){ return 'cache/left_penalty_cursor.json'; }

function bale_left_cursor_read(){
    $cursor = function_exists('bot_data_get_json') ? bot_data_get_json(bale_left_cursor_file(), []) : [];
    return is_array($cursor) ? $cursor : [];
}

function bale_left_cursor_write($cursor){
    if(!is_array($cursor)) $cursor = [];
    $cursor['updated_at'] = time();
    if(function_exists('bot_data_put_json')) return bot_data_put_json(bale_left_cursor_file(), $cursor);
    $file = bale_left_cursor_file();
    $dir = dirname($file); if($dir && !is_dir($dir)) @mkdir($dir, 0775, true);
    return @file_put_contents($file, json_encode($cursor, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT), LOCK_EX) !== false;
}

function bale_left_cursor_reset(){
    return bale_left_cursor_write(['order_id'=>'', 'member_id'=>'', 'wrapped'=>0, 'reset_at'=>time(), 'cycle_checked'=>0, 'cycle_started_at'=>time(), 'cycle_completed_at'=>0]);
}

function bale_left_sorted_ads_files(){
    $files = function_exists('bot_data_glob') ? bot_data_glob('ads/*.json') : (glob('ads/*.json') ?: []);
    $files = array_values(array_filter((array)$files));
    usort($files, function($a, $b){
        return strcmp((string)basename((string)$a, '.json'), (string)basename((string)$b, '.json'));
    });
    return $files;
}

function bale_left_order_is_checkable($order, $cutoff){
    if(!is_array($order)) return false;
    $stamp = isset($order['stamp']) ? (int)$order['stamp'] : 0;
    if($stamp > 0 && $stamp <= $cutoff) return false;
    if(!isset($order['channel']) || trim((string)$order['channel']) === '') return false;
    return true;
}

function bale_left_candidate_members($order){
    $members = isset($order['members']) && is_array($order['members']) ? $order['members'] : [];
    $left = isset($order['left']) && is_array($order['left']) ? $order['left'] : [];
    $out = [];
    foreach($members as $memberId){
        $memberId = (string)$memberId;
        if($memberId === '' || $memberId === '0') continue;
        if(function_exists('bale_member_id_in_array') && bale_member_id_in_array($memberId, $left)) continue;
        $out[$memberId] = $memberId;
    }
    ksort($out, SORT_NATURAL);
    return array_values($out);
}

function bale_left_candidate_summary($cutoff = null){
    if($cutoff === null){
        $days = bale_left_int_setting('leftday', 3, 1, 365);
        $cutoff = strtotime(date('Y-m-d', strtotime("-{$days} day")));
    }
    $memberships = 0;
    $orders = 0;
    $uniqueUsers = [];
    foreach(bale_left_sorted_ads_files() as $adsFile){
        $orderId = (string)basename((string)$adsFile, '.json');
        $order = bot_data_get_json($adsFile);
        if(!is_array($order)) continue;
        bale_hydrate_order_for_left_check($orderId, $order);
        if(!bale_left_order_is_checkable($order, $cutoff)) continue;
        $members = bale_left_candidate_members($order);
        $count = count($members);
        if($count <= 0) continue;
        $orders++;
        $memberships += $count;
        foreach($members as $uid){
            $uid = (string)$uid;
            if($uid !== '' && $uid !== '0') $uniqueUsers[$uid] = true;
        }
    }
    return [
        'total_members'=>$memberships,              // legacy key: membership records, not unique people
        'total_memberships'=>$memberships,
        'unique_users'=>count($uniqueUsers),
        'total_orders'=>$orders,
    ];
}

function bale_left_total_candidate_members($cutoff = null){
    return bale_left_candidate_summary($cutoff);
}

function bale_left_progress_info(){
    $cursor = bale_left_cursor_read();
    $totals = bale_left_total_candidate_members();
    $totalMemberships = max(0, (int)($totals['total_memberships'] ?? ($totals['total_members'] ?? 0)));
    $uniqueUsers = max(0, (int)($totals['unique_users'] ?? 0));
    $done = max(0, (int)($cursor['cycle_checked'] ?? 0));
    if($totalMemberships > 0 && $done > $totalMemberships) $done = $totalMemberships;
    $percent = $totalMemberships > 0 ? round(($done / $totalMemberships) * 100, 2) : 0;
    return [
        'total_members'=>$totalMemberships,
        'total_memberships'=>$totalMemberships,
        'unique_users'=>$uniqueUsers,
        'total_orders'=>(int)($totals['total_orders'] ?? 0),
        'checked_in_cycle'=>$done,
        'percent'=>$percent,
        'cursor'=>$cursor,
        'last_cron_at'=>(int)($cursor['last_cron_at'] ?? 0),
        'last_manual_at'=>(int)($cursor['last_manual_at'] ?? 0),
        'cycle_started_at'=>(int)($cursor['cycle_started_at'] ?? 0),
        'cycle_completed_at'=>(int)($cursor['cycle_completed_at'] ?? 0),
    ];
}

function bale_left_progress_line($info = null){
    if($info === null) $info = bale_left_progress_info();
    $total = (int)($info['total_memberships'] ?? ($info['total_members'] ?? 0));
    $unique = (int)($info['unique_users'] ?? 0);
    $done = (int)($info['checked_in_cycle'] ?? 0);
    $percent = (float)($info['percent'] ?? 0);
    if($total <= 0) return "📊 پیشرفت چرخه فعلی: عضویتی برای بررسی پیدا نشد.";
    return "👥 کاربران یکتای درگیر: <b>{$unique}</b> نفر\n".
           "📊 پیشرفت فنی عضویت‌ها: <b>{$done}</b> از <b>{$total}</b> رکورد عضویت قابل بررسی (<b>{$percent}%</b>)";
}

function bale_left_time_ago_fa($ts){
    $ts = (int)$ts;
    if($ts <= 0) return 'ثبت نشده';
    $diff = max(0, time() - $ts);
    if($diff < 60) return $diff.' ثانیه پیش';
    if($diff < 3600) return floor($diff/60).' دقیقه پیش';
    if($diff < 86400) return floor($diff/3600).' ساعت پیش';
    return date('Y-m-d H:i', $ts);
}

function bale_manual_check_left_all_orders($maxChecks = 60, $notify = true, $source = 'manual'){
    global $data_left;
    $maxChecks = max(1, (int)$maxChecks);
    if(!bale_left_enabled()) return ['checked'=>0,'penalized'=>0,'disabled'=>true];

    $days = bale_left_int_setting('leftday', 3, 1, 365);
    $cutoff = strtotime(date('Y-m-d', strtotime("-{$days} day")));
    $files = bale_left_sorted_ads_files();
    $totalOrders = count($files);
    $totals = bale_left_total_candidate_members($cutoff);
    $totalCandidateMembers = max(0, (int)($totals['total_members'] ?? 0));
    if($totalOrders <= 0){
        bale_left_cursor_reset();
        return ['checked'=>0,'penalized'=>0,'orders_touched'=>0,'errors'=>0,'max_checks'=>$maxChecks,'notify'=>$notify,'cursor'=>['empty'=>true]];
    }

    $cursor = bale_left_cursor_read();
    $cursorOrderId = (string)($cursor['order_id'] ?? '');
    $cursorMemberId = (string)($cursor['member_id'] ?? '');
    $cycleCheckedBefore = max(0, (int)($cursor['cycle_checked'] ?? 0));
    if(empty($cursor['cycle_started_at']) || !empty($cursor['cycle_completed_at']) || $cycleCheckedBefore >= max(1, $totalCandidateMembers)){
        $cycleCheckedBefore = 0;
        $cursor['cycle_started_at'] = time();
        $cursor['cycle_completed_at'] = 0;
        $cursorOrderId = '';
        $cursorMemberId = '';
    }

    $startIndex = 0;
    if($cursorOrderId !== ''){
        foreach($files as $idx=>$adsFile){
            if((string)basename((string)$adsFile, '.json') === $cursorOrderId){ $startIndex = (int)$idx; break; }
        }
    }

    $checked = 0;
    $scanned = 0;
    $penalized = 0;
    $ordersTouched = 0;
    $errors = 0;
    $ordersVisited = 0;
    $checkedUserIds = [];
    $lastOrderId = '';
    $lastMemberId = '';
    $wrapped = false;
    $stop = false;

    for($offset = 0; $offset < $totalOrders && !$stop; $offset++){
        $orderIndex = ($startIndex + $offset) % $totalOrders;
        if($orderIndex < $startIndex) $wrapped = true;
        $adsFile = $files[$orderIndex];
        $orderId = (string)basename((string)$adsFile, '.json');
        $ordersVisited++;

        $order = bot_data_get_json($adsFile);
        if(!is_array($order)) continue;
        bale_hydrate_order_for_left_check($orderId, $order);
        if(!bale_left_order_is_checkable($order, $cutoff)) continue;

        $members = bale_left_candidate_members($order);
        if(empty($members)) continue;

        $startMemberIndex = 0;
        if($offset === 0 && $cursorOrderId !== '' && $orderId === $cursorOrderId && $cursorMemberId !== ''){
            $pos = array_search($cursorMemberId, $members, true);
            if($pos !== false) $startMemberIndex = ((int)$pos) + 1;
            if($startMemberIndex >= count($members)) continue;
        }

        $changed = false;
        for($i = $startMemberIndex; $i < count($members); $i++){
            $memberId = (string)$members[$i];
            $scanned++;
            $lastOrderId = $orderId;
            $lastMemberId = $memberId;

            if(!bale_left_member_exists($memberId)){
                if($scanned >= ($maxChecks * 8)) { $stop = true; break; }
                continue;
            }

            $status = bale_get_member_status_for_left_check($order['channel'], $memberId);
            $checked++;
            $checkedUserIds[$memberId] = true;
            if(bale_is_left_status($status)){
                if(!bale_left_in_grace_period($order, $memberId)){
                    $res = bale_left_confirm_or_penalize($memberId, $orderId, $order, $notify);
                    $changed = true;
                    if(!empty($res['penalized'])) $penalized++;
                }
            }elseif($status === null){
                $errors++;
                bale_left_note_uncertain($order, $memberId, 'api_uncertain');
                $changed = true;
            }else{
                // If user is still a member, clear old candidate counter for this order/member.
                if(isset($order['left_candidates'][$memberId])){
                    unset($order['left_candidates'][$memberId]);
                    $changed = true;
                }
            }

            if($checked >= $maxChecks){
                $stop = true;
                break;
            }
        }
        if($changed){
            saveJson("ads/{$orderId}.json", $order);
            $ordersTouched++;
        }
    }

    $completedCycle = (!$stop) || ($totalCandidateMembers > 0 && ($cycleCheckedBefore + $checked) >= $totalCandidateMembers);
    $cycleCheckedAfter = $completedCycle ? $totalCandidateMembers : min($totalCandidateMembers, $cycleCheckedBefore + $checked);
    $source = preg_replace('/[^a-z0-9_\-]/i', '', (string)$source);
    if($source === '') $source = 'manual';

    if($lastOrderId !== '' && $lastMemberId !== ''){
        $newCursor = [
            'order_id'=>$completedCycle ? '' : $lastOrderId,
            'member_id'=>$completedCycle ? '' : $lastMemberId,
            'checked_in_last_run'=>$checked,
            'scanned_in_last_run'=>$scanned,
            'wrapped'=>$wrapped ? 1 : 0,
            'orders_visited'=>$ordersVisited,
            'cycle_checked'=>$completedCycle ? 0 : $cycleCheckedAfter,
            'cycle_total'=>$totalCandidateMembers,
            'cycle_started_at'=>(int)($cursor['cycle_started_at'] ?? time()),
            'cycle_completed_at'=>$completedCycle ? time() : 0,
        ];
        $newCursor['last_'.$source.'_at'] = time();
        if(isset($cursor['last_cron_at'])) $newCursor['last_cron_at'] = (int)$cursor['last_cron_at'];
        if(isset($cursor['last_manual_at'])) $newCursor['last_manual_at'] = (int)$cursor['last_manual_at'];
        $newCursor['last_'.$source.'_at'] = time();
        bale_left_cursor_write($newCursor);
    }else{
        $newCursor = ['order_id'=>'','member_id'=>'','cycle_checked'=>0,'cycle_total'=>$totalCandidateMembers,'cycle_started_at'=>time(),'cycle_completed_at'=>time()];
        $newCursor['last_'.$source.'_at'] = time();
        bale_left_cursor_write($newCursor);
    }

    $percentAfter = $totalCandidateMembers > 0 ? round((($completedCycle ? $totalCandidateMembers : $cycleCheckedAfter) / $totalCandidateMembers) * 100, 2) : 0;

    return [
        'checked' => $checked,
        'scanned' => $scanned,
        'checked_unique_users' => count($checkedUserIds),
        'penalized' => $penalized,
        'orders_touched' => $ordersTouched,
        'errors' => $errors,
        'max_checks' => $maxChecks,
        'notify' => $notify,
        'stepwise' => true,
        'completed_cycle' => $completedCycle,
        'total_candidates' => $totalCandidateMembers,
        'total_memberships' => $totalCandidateMembers,
        'unique_users' => (int)($totals['unique_users'] ?? 0),
        'total_orders' => (int)($totals['total_orders'] ?? 0),
        'progress_checked' => $completedCycle ? $totalCandidateMembers : $cycleCheckedAfter,
        'progress_percent' => $percentAfter,
        'cursor' => [
            'order_id' => $completedCycle ? '' : $lastOrderId,
            'member_id' => $completedCycle ? '' : $lastMemberId,
            'wrapped' => $wrapped,
            'orders_visited' => $ordersVisited,
        ],
    ];
}

function bale_left_instant_defaults(){
    $batch = bale_left_int_setting('max_checks', 50, 1, 200);
    if($batch < 20) $batch = 20;
    $limit = bale_left_int_setting('instant_max_checks', 500, 1, 5000);
    $seconds = bale_left_int_setting('instant_max_seconds', 25, 5, 45);
    return ['batch'=>$batch, 'limit'=>$limit, 'seconds'=>$seconds];
}

function bale_instant_check_left_all_orders($maxChecks = 0, $notify = true, $source = 'manual_instant', $maxSeconds = 0){
    $defaults = bale_left_instant_defaults();
    $batch = (int)$defaults['batch'];
    $hardLimit = (int)$maxChecks > 0 ? (int)$maxChecks : (int)$defaults['limit'];
    $hardLimit = max(1, min(5000, $hardLimit));
    $maxSeconds = (int)$maxSeconds > 0 ? (int)$maxSeconds : (int)$defaults['seconds'];
    $maxSeconds = max(5, min(45, $maxSeconds));

    if(!bale_left_enabled()) return ['checked'=>0,'penalized'=>0,'disabled'=>true,'instant'=>true];

    // بررسی فوری از cursor فعلی ادامه می‌دهد؛ اگر چرخه قبلی کامل شده باشد، تابع مرحله‌ای خودکار چرخه جدید را شروع می‌کند.

    $started = microtime(true);
    $checked = 0;
    $scanned = 0;
    $penalized = 0;
    $ordersTouched = 0;
    $errors = 0;
    $runs = 0;
    $lastResult = [];
    $completed = false;
    $stoppedByLimit = false;
    $stoppedByTime = false;

    while($checked < $hardLimit){
        if((microtime(true) - $started) >= $maxSeconds){
            $stoppedByTime = true;
            break;
        }
        $remaining = $hardLimit - $checked;
        $thisBatch = max(1, min($batch, $remaining, 200));
        $res = bale_manual_check_left_all_orders($thisBatch, $notify, $source);
        $runs++;
        $lastResult = is_array($res) ? $res : [];

        if(!empty($lastResult['disabled'])){
            $completed = false;
            break;
        }

        $stepChecked = (int)($lastResult['checked'] ?? 0);
        $checked += $stepChecked;
        $scanned += (int)($lastResult['scanned'] ?? 0);
        $penalized += (int)($lastResult['penalized'] ?? 0);
        $ordersTouched += (int)($lastResult['orders_touched'] ?? 0);
        $errors += (int)($lastResult['errors'] ?? 0);

        if(!empty($lastResult['completed_cycle'])){
            $completed = true;
            break;
        }
        if($stepChecked <= 0 && (int)($lastResult['scanned'] ?? 0) <= 0){
            break;
        }
    }

    if($checked >= $hardLimit && !$completed){
        $stoppedByLimit = true;
    }

    $progress = function_exists('bale_left_progress_info') ? bale_left_progress_info() : [];
    $totalMemberships = (int)($lastResult['total_memberships'] ?? ($progress['total_memberships'] ?? ($progress['total_members'] ?? 0)));
    $uniqueUsers = (int)($lastResult['unique_users'] ?? ($progress['unique_users'] ?? 0));
    $progressChecked = (int)($progress['checked_in_cycle'] ?? ($lastResult['progress_checked'] ?? 0));
    $progressPercent = (float)($progress['percent'] ?? ($lastResult['progress_percent'] ?? 0));

    return [
        'checked' => $checked,
        'scanned' => $scanned,
        'penalized' => $penalized,
        'orders_touched' => $ordersTouched,
        'errors' => $errors,
        'instant' => true,
        'runs' => $runs,
        'batch' => $batch,
        'max_checks' => $hardLimit,
        'max_seconds' => $maxSeconds,
        'elapsed_seconds' => round(microtime(true) - $started, 2),
        'completed_cycle' => $completed,
        'stopped_by_limit' => $stoppedByLimit,
        'stopped_by_time' => $stoppedByTime,
        'total_candidates' => $totalMemberships,
        'total_memberships' => $totalMemberships,
        'unique_users' => $uniqueUsers,
        'progress_checked' => $completed ? $totalMemberships : $progressChecked,
        'progress_percent' => $completed ? 100 : $progressPercent,
        'cursor' => is_array($lastResult['cursor'] ?? null) ? $lastResult['cursor'] : ($progress['cursor'] ?? []),
    ];
}

//////////------------------------\\\\\\\\\\\\\\
