<?php
/* GENERATED FIX: true event-driven baseline alert system */
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/DynamicExecutionService.php';
if (file_exists(__DIR__ . '/RiskService.php')) {
    require_once __DIR__ . '/RiskService.php';
}
if (file_exists(__DIR__ . '/SystemConfigService.php')) {
    require_once __DIR__ . '/SystemConfigService.php';
}
if (file_exists(__DIR__ . '/LiveQuoteService.php')) {
    require_once __DIR__ . '/LiveQuoteService.php';
}
if (file_exists(__DIR__ . '/QuoteService.php')) {
    require_once __DIR__ . '/QuoteService.php';
}

if (file_exists(__DIR__ . '/NewsAggregatorService.php')) {
    require_once __DIR__ . '/NewsAggregatorService.php';
}

require_once __DIR__ . '/../vendor/phpmailer/src/Exception.php';
require_once __DIR__ . '/../vendor/phpmailer/src/PHPMailer.php';
require_once __DIR__ . '/../vendor/phpmailer/src/SMTP.php';

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

class AlertService
{
    public static function ensureTables(): void
    {
        db()->exec("CREATE TABLE IF NOT EXISTS institutional_alerts (
            id INT AUTO_INCREMENT PRIMARY KEY,
            user_id INT NOT NULL,
            setup_key VARCHAR(64) NOT NULL,
            symbol VARCHAR(30) NOT NULL,
            timeframe VARCHAR(20) NOT NULL,
            style VARCHAR(50) NULL,
            side VARCHAR(10) NOT NULL,
            scenario_status VARCHAR(100) NULL,
            entry_price DECIMAL(18,8) NULL,
            entry_low DECIMAL(18,8) NULL,
            entry_high DECIMAL(18,8) NULL,
            stop_loss DECIMAL(18,8) NULL,
            tp1 DECIMAL(18,8) NULL,
            tp2 DECIMAL(18,8) NULL,
            tp3 DECIMAL(18,8) NULL,
            alert_trigger_price DECIMAL(18,8) NULL,
            liquidity_sweep_level DECIMAL(18,8) NULL,
            structure_trigger VARCHAR(120) NULL,
            scenario_snapshot LONGTEXT NULL,
            telegram_enabled TINYINT(1) DEFAULT 0,
            email_enabled TINYINT(1) DEFAULT 0,
            lifecycle_stage VARCHAR(50) DEFAULT 'TRACKING',
            is_active TINYINT(1) DEFAULT 1,
            last_checked_at DATETIME NULL,
            triggered_at DATETIME NULL,
            last_sent_at DATETIME NULL,
            send_count INT DEFAULT 0,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uniq_user_setup (user_id, setup_key, side)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        db()->exec("CREATE TABLE IF NOT EXISTS institutional_alert_logs (
            id INT AUTO_INCREMENT PRIMARY KEY,
            alert_id INT NOT NULL,
            user_id INT NOT NULL,
            channel VARCHAR(30) NOT NULL,
            status VARCHAR(30) NOT NULL,
            message TEXT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX idx_alert_id (alert_id),
            INDEX idx_user_id (user_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        db()->exec("CREATE TABLE IF NOT EXISTS alert_templates (
            id INT AUTO_INCREMENT PRIMARY KEY,
            template_key VARCHAR(100) NOT NULL UNIQUE,
            subject VARCHAR(255) NULL,
            body TEXT NOT NULL,
            channel VARCHAR(30) NOT NULL DEFAULT 'email',
            is_active TINYINT(1) DEFAULT 1,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        self::ensureSuggestedSetupsTable();

        
        /*
        |--------------------------------------------------------------------------
        | User alert/news preference schema safety
        |--------------------------------------------------------------------------
        */

        self::addColumnIfMissing('users', 'avoid_news_trades', "TINYINT(1) DEFAULT 0");
        self::addColumnIfMissing('users', 'trade_high_impact_news', "TINYINT(1) DEFAULT 1");
        self::addColumnIfMissing('users', 'trade_medium_impact_news', "TINYINT(1) DEFAULT 1");
        self::addColumnIfMissing('users', 'news_block_minutes_before', "INT DEFAULT 60");
        self::addColumnIfMissing('users', 'news_block_minutes_after', "INT DEFAULT 30");
        self::addColumnIfMissing('users', 'notify_stage_1', "TINYINT(1) DEFAULT 1");
        self::addColumnIfMissing('users', 'notify_stage_2', "TINYINT(1) DEFAULT 1");
        self::addColumnIfMissing('users', 'notify_stage_3', "TINYINT(1) DEFAULT 1");
        self::addColumnIfMissing('users', 'notify_final_setup', "TINYINT(1) DEFAULT 1");

        self::ensureAlertSchema();

        if (class_exists('LiveQuoteService') && method_exists('LiveQuoteService', 'ensureTables')) {
            LiveQuoteService::ensureTables();
        }

        self::seedDefaultTemplates();
    }

    private static function ensureAlertSchema(): void
    {
        /*
        |--------------------------------------------------------------------------
        | Phase 1 production safety schema upgrades
        |--------------------------------------------------------------------------
        | These ALTERs are wrapped safely so older databases are upgraded without
        | breaking existing installations.
        |--------------------------------------------------------------------------
        */

        self::addColumnIfMissing('institutional_alerts', 'last_final_signature', "VARCHAR(64) NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_stage_signature', "VARCHAR(64) NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_stage_sent_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_final_attempt_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_closed_candle_time', "DATETIME NULL");

        self::addColumnIfMissing('institutional_alert_logs', 'signature', "VARCHAR(64) NULL");
        self::addColumnIfMissing('institutional_alert_logs', 'stage', "VARCHAR(50) NULL");

        self::addIndexIfMissing('institutional_alert_logs', 'idx_alert_stage_signature', '(alert_id, channel, stage, signature)');

        self::addColumnIfMissing('institutional_alerts', 'last_quality_score', "DECIMAL(6,2) NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_quality_reason', "TEXT NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_market_snapshot', "LONGTEXT NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_evaluation_snapshot', "LONGTEXT NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_block_reason', "TEXT NULL");

        /*
        |--------------------------------------------------------------------------
        | Final execution checklist / debug report
        |--------------------------------------------------------------------------
        | Stores the exact pass/fail checklist used before a final execution alert
        | is allowed to go out.
        |--------------------------------------------------------------------------
        */
        self::addColumnIfMissing('institutional_alerts', 'last_execution_checklist', "LONGTEXT NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_execution_checklist_status', "VARCHAR(30) NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_execution_checklist_reason', "TEXT NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_execution_checklist_at', "DATETIME NULL");

        /*
        |--------------------------------------------------------------------------
        | Phase 3 duplicate prevention + cooldown schema
        |--------------------------------------------------------------------------
        */

        self::addColumnIfMissing('institutional_alerts', 'last_alert_fingerprint', "VARCHAR(64) NULL");
        self::addColumnIfMissing('institutional_alerts', 'cooldown_until', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage1_sent_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage2_sent_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage3_sent_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'final_sent_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'duplicate_block_count', "INT DEFAULT 0");
        self::addColumnIfMissing('institutional_alerts', 'last_duplicate_blocked_at', "DATETIME NULL");

        self::addIndexIfMissing('institutional_alerts', 'idx_alert_cooldown', '(is_active, triggered_at, cooldown_until)');

        /*
        |--------------------------------------------------------------------------
        | Phase 4 multi-timeframe confirmation schema
        |--------------------------------------------------------------------------
        */

        self::addColumnIfMissing('institutional_alerts', 'last_mtf_status', "VARCHAR(50) NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_mtf_reason', "TEXT NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_mtf_checked_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_mtf_score', "DECIMAL(6,2) NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_mtf_strictness', "VARCHAR(50) NULL");

        /*
        |--------------------------------------------------------------------------
        | Phase 5 candle-close confirmation schema
        |--------------------------------------------------------------------------
        */

        self::addColumnIfMissing('institutional_alerts', 'last_candle_confirmation', "VARCHAR(50) NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_candle_checked_at', "DATETIME NULL");
        /*
        |--------------------------------------------------------------------------
        | Phase 6 setup expiration / cleanup schema
        |--------------------------------------------------------------------------
        */

        self::addColumnIfMissing('institutional_alerts', 'tracking_started_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'tracking_started_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'dev_test_alert', "TINYINT(1) DEFAULT 0");

        /*
         * Event-driven alert baseline:
         * First cron check records existing market state only. Anything already
         * present at tracking time is context, not a live alert.
         */
        self::addColumnIfMissing('institutional_alerts', 'baseline_captured_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'baseline_stage', "VARCHAR(50) NULL");
        self::addColumnIfMissing('institutional_alerts', 'baseline_zone_touched', "TINYINT(1) DEFAULT 0");
        self::addColumnIfMissing('institutional_alerts', 'baseline_liquidity_swept', "TINYINT(1) DEFAULT 0");
        self::addColumnIfMissing('institutional_alerts', 'baseline_structure_confirmed', "TINYINT(1) DEFAULT 0");
        self::addColumnIfMissing('institutional_alerts', 'baseline_current_price', "DECIMAL(20,8) NULL");
        self::addColumnIfMissing('institutional_alerts', 'baseline_captured_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'baseline_closed_candle_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'last_processed_closed_candle_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage1_sent_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage2_sent_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage3_sent_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'final_sent_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage1_event_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage2_event_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage3_event_ts', "BIGINT NULL");
        self::addColumnIfMissing('institutional_alerts', 'stage_event_guard', "VARCHAR(50) NULL");

        self::addColumnIfMissing('institutional_alerts', 'expired_at', "DATETIME NULL");
        self::addColumnIfMissing('institutional_alerts', 'expiration_reason', "TEXT NULL");
        self::addColumnIfMissing('institutional_alerts', 'auto_expire_enabled', "TINYINT(1) DEFAULT 0");
        self::addColumnIfMissing('institutional_alerts', 'expires_at', "DATETIME NULL");

        self::addIndexIfMissing('institutional_alerts', 'idx_alert_expiration', '(is_active, triggered_at, expired_at, created_at)');
        self::addIndexIfMissing('institutional_alerts', 'idx_alert_tracking_started', '(is_active, triggered_at, tracking_started_ts)');
        self::addIndexIfMissing('institutional_alerts', 'idx_alert_baseline_captured', '(is_active, triggered_at, baseline_captured_at)');
        self::addIndexIfMissing('institutional_alerts', 'idx_alert_event_state', '(is_active, triggered_at, stage1_sent_ts, stage2_sent_ts, stage3_sent_ts)');

    }

    private static function addColumnIfMissing(string $table, string $column, string $definition): void
    {
        try {
            $stmt = db()->prepare("
                SELECT COUNT(*)
                FROM INFORMATION_SCHEMA.COLUMNS
                WHERE TABLE_SCHEMA = DATABASE()
                  AND TABLE_NAME = ?
                  AND COLUMN_NAME = ?
            ");
            $stmt->execute([$table, $column]);

            if ((int)$stmt->fetchColumn() === 0) {
                db()->exec("ALTER TABLE {$table} ADD COLUMN {$column} {$definition}");
            }
        } catch (Throwable $e) {
        }
    }

    private static function addIndexIfMissing(string $table, string $index, string $columns): void
    {
        try {
            $stmt = db()->prepare("
                SELECT COUNT(*)
                FROM INFORMATION_SCHEMA.STATISTICS
                WHERE TABLE_SCHEMA = DATABASE()
                  AND TABLE_NAME = ?
                  AND INDEX_NAME = ?
            ");
            $stmt->execute([$table, $index]);

            if ((int)$stmt->fetchColumn() === 0) {
                db()->exec("ALTER TABLE {$table} ADD INDEX {$index} {$columns}");
            }
        } catch (Throwable $e) {
        }
    }

    private static function ensureSuggestedSetupsTable(): void
    {
        try {
            db()->exec("CREATE TABLE IF NOT EXISTS institutional_suggested_setups (
                id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                suggestion_uid VARCHAR(80) NOT NULL UNIQUE,
                source_alert_id INT NOT NULL,
                user_id INT NOT NULL,
                source_setup_key VARCHAR(100) NULL,
                symbol VARCHAR(30) NOT NULL,
                timeframe VARCHAR(20) NOT NULL,
                confirmation_timeframe VARCHAR(20) NULL,
                style VARCHAR(50) NULL,
                side VARCHAR(10) NOT NULL,
                original_entry_price DECIMAL(20,8) NULL,
                original_entry_low DECIMAL(20,8) NULL,
                original_entry_high DECIMAL(20,8) NULL,
                suggested_entry_price DECIMAL(20,8) NULL,
                suggested_entry_low DECIMAL(20,8) NULL,
                suggested_entry_high DECIMAL(20,8) NULL,
                stop_loss DECIMAL(20,8) NULL,
                tp1 DECIMAL(20,8) NULL,
                tp2 DECIMAL(20,8) NULL,
                tp3 DECIMAL(20,8) NULL,
                current_price DECIMAL(20,8) NULL,
                quality_score DECIMAL(6,2) NULL,
                confidence_label VARCHAR(50) NULL,
                status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
                reason TEXT NULL,
                htf_location_label VARCHAR(120) NULL,
                htf_location_score DECIMAL(6,2) NULL,
                ltf_location_label VARCHAR(120) NULL,
                ltf_location_score DECIMAL(6,2) NULL,
                location_gate_reason TEXT NULL,
                notification_sent_at DATETIME NULL,
                email_notified_at DATETIME NULL,
                telegram_notified_at DATETIME NULL,
                notification_attempted_at DATETIME NULL,
                notification_count INT DEFAULT 0,
                conditions_json LONGTEXT NULL,
                execution_snapshot LONGTEXT NULL,
                market_snapshot LONGTEXT NULL,
                evaluation_snapshot LONGTEXT NULL,
                expires_at DATETIME NULL,
                accepted_at DATETIME NULL,
                ignored_at DATETIME NULL,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                INDEX idx_user_status (user_id, status, created_at),
                INDEX idx_source_alert (source_alert_id),
                INDEX idx_symbol_side (symbol, side, status),
                INDEX idx_expires (status, expires_at)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

            self::addColumnIfMissing('institutional_suggested_setups', 'htf_location_label', "VARCHAR(120) NULL AFTER reason");
            self::addColumnIfMissing('institutional_suggested_setups', 'htf_location_score', "DECIMAL(6,2) NULL AFTER htf_location_label");
            self::addColumnIfMissing('institutional_suggested_setups', 'ltf_location_label', "VARCHAR(120) NULL AFTER htf_location_score");
            self::addColumnIfMissing('institutional_suggested_setups', 'ltf_location_score', "DECIMAL(6,2) NULL AFTER ltf_location_label");
            self::addColumnIfMissing('institutional_suggested_setups', 'location_gate_reason', "TEXT NULL AFTER ltf_location_score");
            self::addColumnIfMissing('institutional_suggested_setups', 'notification_sent_at', "DATETIME NULL AFTER location_gate_reason");
            self::addColumnIfMissing('institutional_suggested_setups', 'email_notified_at', "DATETIME NULL AFTER notification_sent_at");
            self::addColumnIfMissing('institutional_suggested_setups', 'telegram_notified_at', "DATETIME NULL AFTER email_notified_at");
            self::addColumnIfMissing('institutional_suggested_setups', 'notification_attempted_at', "DATETIME NULL AFTER telegram_notified_at");
            self::addColumnIfMissing('institutional_suggested_setups', 'notification_count', "INT DEFAULT 0 AFTER notification_attempted_at");
        } catch (Throwable $e) {
        }
    }

    private static function seedDefaultTemplates(): void
    {
        $defaults = [
            ['institutional_alert_email_subject', 'email_subject', '{{symbol}} {{side}} Institutional Setup Ready'],
            ['institutional_alert_email_body', 'email', self::defaultHtmlEmailTemplate()],
            ['institutional_alert_telegram_body', 'telegram', "<b>Institutional Setup Ready</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n\n<b>Entry Zone:</b> {{entry_low}} - {{entry_high}}\n<b>Suggested Entry:</b> {{entry_price}}\n<b>Stop Loss:</b> {{stop_loss}}\n<b>TP1:</b> {{tp1}}\n<b>TP2:</b> {{tp2}}\n<b>TP3:</b> {{tp3}}\n\n<b>Confirmed:</b>\n- Institutional zone reached\n- Liquidity sweep detected\n- BOS / CHoCH confirmed\n\nUse proper risk management."],
            ['stage_zone_approached_telegram', 'telegram', "<b>Stage 1: Zone Approached</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n<b>Tracked Zone:</b> {{entry_zone}}\n\n<b>Next Step:</b> Wait for a fresh liquidity sweep."],
            ['stage_liquidity_swept_telegram', 'telegram', "<b>Stage 2: Liquidity Swept</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n<b>Tracked Zone:</b> {{entry_zone}}\n\n<b>Next Step:</b> Wait for displacement / BOS / CHoCH confirmation."],
            ['stage_structure_confirmed_telegram', 'telegram', "<b>Stage 3: Structure Confirmed</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n<b>Tracked Zone:</b> {{entry_zone}}\n\n<b>Next Step:</b> Wait for final candle confirmation and quality filters."],
            ['stage_zone_approached_email', 'email', self::defaultStageZoneEmailTemplate()],
            ['stage_liquidity_swept_email', 'email', self::defaultStageSweepEmailTemplate()],
            ['stage_structure_confirmed_email', 'email', self::defaultStageStructureEmailTemplate()]
        ];

        foreach ($defaults as $tpl) {
            try {
                $stmt = db()->prepare("
                    INSERT INTO alert_templates (template_key, channel, body, is_active)
                    VALUES (?, ?, ?, 1)
                    ON DUPLICATE KEY UPDATE template_key = template_key
                ");
                $stmt->execute($tpl);
            } catch (Throwable $e) {
            }
        }
    }

    private static function captureImmediateBaselineForTrackedSetup(int $userId, string $setupKey): void
    {
        /*
         * Production tracking rule:
         * Baseline should be captured immediately when the user clicks Track,
         * not delayed until the first alert cron run. Delaying baseline capture
         * can miss fast crypto/indices moves that touch the zone before the next
         * worker cycle.
         */
        try {
            if ($setupKey === '') {
                return;
            }

            $stmt = db()->prepare("
                SELECT *
                FROM institutional_alerts
                WHERE user_id = ?
                  AND setup_key = ?
                ORDER BY id DESC
                LIMIT 1
            ");
            $stmt->execute([$userId, $setupKey]);
            $alert = $stmt->fetch(PDO::FETCH_ASSOC);

            if (!$alert || !empty($alert['baseline_captured_at'])) {
                return;
            }

            $market = self::buildMarketState($alert);
            $evaluation = self::evaluateSequencedAlert($alert, $market);
            $baselineStage = (string)($evaluation['raw_lifecycle_stage'] ?? $evaluation['lifecycle_stage'] ?? 'TRACKING');

            self::captureBaseline((int)$alert['id'], $baselineStage, $evaluation, $market);

            $baselineEval = $evaluation;
            $baselineEval['lifecycle_stage'] = 'TRACKING';
            $baselineEval['message'] = 'Baseline captured immediately when setup tracking was activated.';
            $baselineEval['ready'] = false;

            self::markChecked((int)$alert['id'], 'TRACKING', $market, $baselineEval);
        } catch (Throwable $e) {
            error_log('[AlertService immediate baseline] ' . $e->getMessage());
        }
    }



    public static function saveTrackedSetup(int $userId, array $post): array
    {
        self::ensureTables();

        /*
         * Use PHP's real timestamp for alert tracking windows.
         * This avoids MySQL/server timezone mismatch causing old candles to be scanned.
         */
        $trackingStartedTs = time();
        $trackingStartedAt = date('Y-m-d H:i:s', $trackingStartedTs);


        
        $expiration = self::resolveExpirationSettings($post);
$telegram = !empty($post['telegram']) ? 1 : 0;
        $email = !empty($post['email']) ? 1 : 0;

        if (!$telegram && !$email) {
            return ['ok' => false, 'message' => 'Select Telegram, Email, or both.'];
        }

        self::updateUserAlertPreferences($userId, $post);

        $choice = strtolower((string)($post['scenario_to_track'] ?? 'preferred'));
        $preferred = self::scenarioFromPost($post, 'preferred');
        $buy = self::jsonScenario($post['buy_scenario_json'] ?? '');
        $sell = self::jsonScenario($post['sell_scenario_json'] ?? '');

        /*
         * Important:
         * If the user explicitly tracks BUY or SELL, do not fall back to the
         * preferred scenario values when that selected scenario has no suggested
         * entry yet. That was why a BUY setup with "Wait for trigger" could be
         * saved using the SELL entry/SL/TP.
         */
        $explicitScenarioChoice = in_array($choice, ['buy', 'sell'], true);

        if ($choice === 'buy') {
            $scenario = is_array($buy) ? $buy : [];
            if (empty($scenario)) {
                return ['ok' => false, 'message' => 'The selected BUY scenario is not available yet.'];
            }
        } elseif ($choice === 'sell') {
            $scenario = is_array($sell) ? $sell : [];
            if (empty($scenario)) {
                return ['ok' => false, 'message' => 'The selected SELL scenario is not available yet.'];
            }
        } else {
            $scenario = $preferred;
        }

        $scenarioValue = static function (array $scenarioData, string $key, array $postData, string $fallbackPostKey, bool $allowPreferredFallback) {
            if (array_key_exists($key, $scenarioData)) {
                $value = $scenarioData[$key];

                if ($value !== null && $value !== '' && !is_array($value)) {
                    return $value;
                }

                return $allowPreferredFallback ? ($postData[$fallbackPostKey] ?? null) : null;
            }

            return $allowPreferredFallback ? ($postData[$fallbackPostKey] ?? null) : null;
        };

        $nestedScenarioValue = static function (array $scenarioData, string $parent, string $key, array $postData, string $fallbackPostKey, bool $allowPreferredFallback) {
            if (isset($scenarioData[$parent]) && is_array($scenarioData[$parent]) && array_key_exists($key, $scenarioData[$parent])) {
                $value = $scenarioData[$parent][$key];

                if ($value !== null && $value !== '' && !is_array($value)) {
                    return $value;
                }

                return $allowPreferredFallback ? ($postData[$fallbackPostKey] ?? null) : null;
            }

            return $allowPreferredFallback ? ($postData[$fallbackPostKey] ?? null) : null;
        };

        $allowPreferredFallback = !$explicitScenarioChoice;

        $side = strtoupper((string)($scenario['side'] ?? ($allowPreferredFallback ? ($post['preferred_side'] ?? '') : $choice)));

        if (!in_array($side, ['BUY', 'SELL'], true)) {
            return ['ok' => false, 'message' => 'No valid scenario found.'];
        }

        if ($explicitScenarioChoice && strtoupper($choice) !== $side) {
            return ['ok' => false, 'message' => 'Selected scenario mismatch. Please refresh the dashboard and track the setup again.'];
        }

        $symbol = strtoupper(trim((string)($post['symbol'] ?? '')));
        $timeframe = trim((string)($post['timeframe'] ?? ''));
        $style = trim((string)($post['style'] ?? ''));

        $entry = self::num($scenarioValue($scenario, 'entry', $post, 'preferred_entry', $allowPreferredFallback));
        $low = self::num($scenarioValue($scenario, 'entry_low', $post, 'preferred_entry_low', $allowPreferredFallback));
        $high = self::num($scenarioValue($scenario, 'entry_high', $post, 'preferred_entry_high', $allowPreferredFallback));
        $sl = self::num($scenarioValue($scenario, 'stop_loss', $post, 'preferred_stop_loss', $allowPreferredFallback));
        $tp1 = self::num($scenarioValue($scenario, 'tp1', $post, 'preferred_tp1', $allowPreferredFallback));
        $tp2 = self::num($scenarioValue($scenario, 'tp2', $post, 'preferred_tp2', $allowPreferredFallback));
        $tp3 = self::num($scenarioValue($scenario, 'tp3', $post, 'preferred_tp3', $allowPreferredFallback));
        $trigger = self::num($scenarioValue($scenario, 'alert_trigger_price', $post, 'preferred_alert_trigger_price', $allowPreferredFallback));
        $sweep = self::num($nestedScenarioValue($scenario, 'sweep_area', 'level', $post, 'preferred_liquidity_sweep_level', $allowPreferredFallback));
        $structure = (string)($nestedScenarioValue($scenario, 'structure_shift', 'label', $post, 'preferred_structure_trigger', $allowPreferredFallback) ?? '');

        /*
         * If a scenario is only a watchlist/zone-tapped setup and has no trade
         * entry yet, keep it as a valid tracked watchlist but do not save the
         * opposite scenario's entry/SL/targets.
         */
        if ($explicitScenarioChoice && $entry === null) {
            $scenario['entry'] = null;
            $scenario['entry_note'] = $scenario['entry_note'] ?? 'Waiting for valid entry trigger.';
        }

        $setupKey = (string)($post['setup_key'] ?? '');

        if (!$setupKey) {
            $setupKey = md5($userId . '|' . $symbol . '|' . $timeframe . '|' . $side . '|' . $low . '|' . $high . '|' . $sl . '|' . $sweep);
        }

        $snapshot = json_encode($scenario, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        $stmt = db()->prepare("
            INSERT INTO institutional_alerts
            (user_id, setup_key, symbol, timeframe, style, side, scenario_status,
             entry_price, entry_low, entry_high, stop_loss, tp1, tp2, tp3,
             alert_trigger_price, liquidity_sweep_level, structure_trigger,
             scenario_snapshot, telegram_enabled, email_enabled,
             auto_expire_enabled, expires_at,
             tracking_started_at, tracking_started_ts,
             lifecycle_stage, is_active)
            VALUES
            (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'TRACKING', 1)
            ON DUPLICATE KEY UPDATE
                scenario_status = VALUES(scenario_status),
                entry_price = VALUES(entry_price),
                entry_low = VALUES(entry_low),
                entry_high = VALUES(entry_high),
                stop_loss = VALUES(stop_loss),
                tp1 = VALUES(tp1),
                tp2 = VALUES(tp2),
                tp3 = VALUES(tp3),
                alert_trigger_price = VALUES(alert_trigger_price),
                liquidity_sweep_level = VALUES(liquidity_sweep_level),
                structure_trigger = VALUES(structure_trigger),
                scenario_snapshot = VALUES(scenario_snapshot),
                telegram_enabled = VALUES(telegram_enabled),
                email_enabled = VALUES(email_enabled),
                auto_expire_enabled = VALUES(auto_expire_enabled),
                expires_at = VALUES(expires_at),
                tracking_started_at = VALUES(tracking_started_at),
                tracking_started_ts = VALUES(tracking_started_ts),
                baseline_captured_at = NULL,
                baseline_stage = NULL,
                baseline_zone_touched = 0,
                baseline_liquidity_swept = 0,
                baseline_structure_confirmed = 0,
                baseline_current_price = NULL,
                baseline_captured_ts = NULL,
                baseline_closed_candle_ts = NULL,
                last_processed_closed_candle_ts = NULL,
                stage1_sent_ts = NULL,
                stage2_sent_ts = NULL,
                stage3_sent_ts = NULL,
                final_sent_ts = NULL,
                stage1_event_ts = NULL,
                stage2_event_ts = NULL,
                stage3_event_ts = NULL,
                expired_at = NULL,
                expiration_reason = NULL,
                lifecycle_stage = 'TRACKING',
                is_active = 1,
                triggered_at = NULL,
                last_checked_at = NULL,
                last_sent_at = NULL,
                final_sent_at = NULL,
                stage1_sent_at = NULL,
                stage2_sent_at = NULL,
                stage3_sent_at = NULL,
                last_stage_signature = NULL,
                last_final_signature = NULL,
                last_alert_fingerprint = NULL,
                cooldown_until = NULL,
                last_market_snapshot = NULL,
                last_evaluation_snapshot = NULL,
                last_block_reason = NULL,
                duplicate_block_count = 0,
                last_duplicate_blocked_at = NULL,
                send_count = 0,
                created_at = NOW(),
                updated_at = NOW()
        ");

        $stmt->execute([
            $userId, $setupKey, $symbol, $timeframe, $style, $side, $scenario['status'] ?? null,
            $entry, $low, $high, $sl, $tp1, $tp2, $tp3,
            $trigger, $sweep, $structure, $snapshot, $telegram, $email,
            (int)$expiration['enabled'], $expiration['expires_at'],
            $trackingStartedAt, $trackingStartedTs
        ]);

        self::captureImmediateBaselineForTrackedSetup($userId, $setupKey);

        if ($explicitScenarioChoice && $entry === null) {
            return [
                'ok' => true,
                'message' => strtoupper($choice) . ' setup tracked as watchlist. No suggested entry exists yet, so entry/SL/TP were saved as waiting instead of borrowing another scenario.'
            ];
        }

        return ['ok' => true, 'message' => 'Alert tracking activated.'];
    }

    private static function scenarioFromPost(array $post, string $prefix): array
    {
        return [
            'side' => $post[$prefix . '_side'] ?? '',
            'status' => $post[$prefix . '_status'] ?? '',
            'entry' => $post[$prefix . '_entry'] ?? null,
            'entry_low' => $post[$prefix . '_entry_low'] ?? null,
            'entry_high' => $post[$prefix . '_entry_high'] ?? null,
            'stop_loss' => $post[$prefix . '_stop_loss'] ?? null,
            'tp1' => $post[$prefix . '_tp1'] ?? null,
            'tp2' => $post[$prefix . '_tp2'] ?? null,
            'tp3' => $post[$prefix . '_tp3'] ?? null,
            'alert_trigger_price' => $post[$prefix . '_alert_trigger_price'] ?? null,
            'sweep_area' => ['level' => $post[$prefix . '_liquidity_sweep_level'] ?? null],
            'structure_shift' => ['label' => $post[$prefix . '_structure_trigger'] ?? null],
        ];
    }

    private static function jsonScenario($json): array
    {
        if (!$json) {
            return [];
        }

        $decoded = json_decode((string)$json, true);

        return is_array($decoded) ? $decoded : [];
    }

    public static function monitorActiveAlerts(): array
    {
        self::ensureTables();

        $stmt = db()->query("
            SELECT a.*, u.email, u.telegram_chat_id, u.name,
                   COALESCE(u.avoid_news_trades,0) AS avoid_news_trades,
                   COALESCE(u.trade_high_impact_news,1) AS trade_high_impact_news,
                   COALESCE(u.trade_medium_impact_news,1) AS trade_medium_impact_news,
                   COALESCE(u.news_block_minutes_before,60) AS news_block_minutes_before,
                   COALESCE(u.news_block_minutes_after,30) AS news_block_minutes_after,
                   COALESCE(u.notify_stage_1,1) AS notify_stage_1,
                   COALESCE(u.notify_stage_2,1) AS notify_stage_2,
                   COALESCE(u.notify_stage_3,1) AS notify_stage_3,
                   COALESCE(u.notify_final_setup,1) AS notify_final_setup
            FROM institutional_alerts a
            LEFT JOIN users u ON u.id = a.user_id
            WHERE a.is_active = 1
              AND a.triggered_at IS NULL
            ORDER BY a.created_at ASC
            LIMIT 100
        ");

        $alerts = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
        $results = [];

        foreach ($alerts as $alert) {
            if (self::alertExpired($alert)) {
                self::expireAlert((int)$alert['id'], 'Setup expired based on timeframe age rules.');
                $results[] = [
                    'alert_id' => (int)$alert['id'],
                    'ready' => false,
                    'stage' => 'EXPIRED',
                    'message' => 'Setup expired and was deactivated.',
                    'sent' => false
                ];
                continue;
            }

            $market = self::buildMarketState($alert);
            $evaluation = self::evaluateSequencedAlert($alert, $market);
            $baselineCaptured = !empty($alert['baseline_captured_at']);
            $developerMode = self::developerSimulationEnabled($alert);

            /*
             * First live check = baseline capture.
             * Production correction:
             * If the market has already reached the zone by the time the first
             * worker run captures baseline, do NOT leave the setup stuck forever
             * with baseline_stage=ZONE_APPROACHED and Stage 1 pending. That was
             * the cause of valid pullbacks being missed on EURUSD/XAUUSD.
             *
             * Stage 1 is an informational zone-reached event, so it is allowed
             * to be delivered immediately when the baseline itself proves the
             * zone has been reached. Stage 2/3 still remain sequential.
             */
            if (!$developerMode && !$baselineCaptured) {
                $baselineStage = strtoupper((string)($evaluation['raw_lifecycle_stage'] ?? $evaluation['lifecycle_stage'] ?? 'TRACKING'));
                self::captureBaseline((int)$alert['id'], $baselineStage, $evaluation, $market);

                $stageNotifications = [];
                $stageSentCount = 0;
                $visibleStage = 'TRACKING';
                $baselineMessage = 'Baseline captured. Waiting for fresh market action after tracking started.';

                $baselineReachedZone = !empty($evaluation['zone_reached']) || !empty($market['zone_touched']);
                $shouldPromoteBaselineStage1 = self::settingEnabled('stage1_on_baseline_zone_reached', true)
                    && $baselineReachedZone
                    && !self::stageAlreadySent($alert, 'ZONE_APPROACHED');

                if ($shouldPromoteBaselineStage1) {
                    $stageEvaluation = $evaluation;
                    $stageEvaluation['lifecycle_stage'] = 'ZONE_APPROACHED';
                    $stageEvaluation['message'] = 'Stage 1: Zone reached by the first baseline check. Tracking continues to wait for liquidity sweep.';
                    $stageEvaluation['ready'] = false;

                    $stageDelivery = self::sendStageAlert($alert, $market, $stageEvaluation);
                    $stageNotifications[] = $stageDelivery;

                    if (!empty($stageDelivery['delivered'])) {
                        $stageSentCount = 1;
                        $visibleStage = 'ZONE_APPROACHED';
                        $baselineMessage = 'Stage 1 delivered from baseline zone reach. Waiting for liquidity sweep.';
                        self::markChecked((int)$alert['id'], 'ZONE_APPROACHED', $market, $stageEvaluation);
                    } else {
                        $baselineEval = $evaluation;
                        $baselineEval['lifecycle_stage'] = 'TRACKING';
                        $baselineEval['message'] = 'Baseline captured with zone reached, but Stage 1 delivery did not complete.';
                        $baselineEval['ready'] = false;
                        self::markChecked((int)$alert['id'], 'TRACKING', $market, $baselineEval);
                        $baselineMessage = $baselineEval['message'];
                    }
                } else {
                    $baselineEval = $evaluation;
                    $baselineEval['lifecycle_stage'] = 'TRACKING';
                    $baselineEval['message'] = $baselineMessage;
                    $baselineEval['ready'] = false;
                    self::markChecked((int)$alert['id'], 'TRACKING', $market, $baselineEval);
                }

                $results[] = [
                    'alert_id' => (int)$alert['id'],
                    'ready' => false,
                    'stage' => $visibleStage,
                    'message' => $baselineMessage,
                    'baseline_captured' => true,
                    'baseline_detected_stage' => $baselineStage,
                    'stage_notifications' => $stageNotifications,
                    'stage_sent' => $stageSentCount,
                    'sent' => $stageSentCount > 0
                ];

                continue;
            }

            $currentSentStage = self::highestSentStage($alert);
            $stageToSend = null;

            if ($developerMode && self::settingEnabled('simulate_ready_setup')) {
                /*
                 * Developer-only testing. Normal live alerts never use this path.
                 */
                foreach (['ZONE_APPROACHED', 'LIQUIDITY_SWEPT', 'STRUCTURE_CONFIRMED'] as $candidateStage) {
                    if (!self::stageAlreadySent($alert, $candidateStage)) {
                        $stageToSend = $candidateStage;
                        break;
                    }
                }
            } else {
                $candidateStage = (string)($evaluation['lifecycle_stage'] ?? 'TRACKING');

                if (
                    in_array($candidateStage, ['ZONE_APPROACHED', 'LIQUIDITY_SWEPT', 'STRUCTURE_CONFIRMED'], true)
                    && self::lifecycleRank($candidateStage) > self::lifecycleRank($currentSentStage)
                ) {
                    $stageToSend = $candidateStage;
                }
            }

            $stageNotifications = [];
            $stageSentCount = 0;
            $visibleStage = $currentSentStage;

            if ($stageToSend !== null) {
                $stageEvaluation = $evaluation;
                $stageEvaluation['lifecycle_stage'] = $stageToSend;
                $stageEvaluation['message'] = self::stagePlainMessage($stageToSend);

                if (!$developerMode && !self::stageHasLiveEvidence($stageToSend, $market)) {
                    $delivery = [
                        'stage' => $stageToSend,
                        'attempted' => false,
                        'delivered' => false,
                        'telegram' => 'waiting',
                        'email' => 'waiting',
                        'skipped_reason' => 'missing_live_market_evidence',
                    ];

                    self::logAlert(
                        (int)$alert['id'],
                        (int)$alert['user_id'],
                        'stage_validation',
                        'blocked',
                        'Stage alert blocked because live market evidence does not confirm ' . $stageToSend . '.',
                        null,
                        $stageToSend
                    );
                } else {
                    $delivery = self::sendStageAlert($alert, $market, $stageEvaluation);
                }

                $stageNotifications[] = $delivery;

                if (!empty($delivery['delivered'])) {
                    $stageSentCount = 1;
                    $visibleStage = $stageToSend;
                }
            }

            /*
             * Final alert only after Stage 3 has actually been sent before.
             * A raw structure detection alone is not enough for final execution.
             */
            $stage3Sent = !empty($alert['stage3_sent_at']) || $visibleStage === 'STRUCTURE_CONFIRMED';
            if (!empty($evaluation['ready']) && $stage3Sent) {
                $finalResult = self::dispatchFinalAlert($alert, $market, $evaluation);
                $finalResult['stage_notifications'] = $stageNotifications;
                $finalResult['stage_sent'] = $stageSentCount;
                $finalResult['stage_delivery'] = self::stageDeliveryState($alert, $visibleStage);

                if ($stageSentCount > 0) {
                    $finalResult['sent'] = true;
                }

                $results[] = $finalResult;
                continue;
            }

            /*
             * The visible lifecycle follows only delivered stages.
             * Detection without delivery should not change My Alerts status.
             */
            $displayEval = $evaluation;
            $displayEval['lifecycle_stage'] = $visibleStage;
            if ($visibleStage === 'TRACKING') {
                $displayEval['message'] = 'Tracking active. Waiting for fresh market action after tracking started.';
            }

            self::markChecked((int)$alert['id'], $visibleStage, $market, $displayEval);

            $results[] = [
                'alert_id' => (int)$alert['id'],
                'ready' => false,
                'stage' => $visibleStage,
                'candidate_stage' => $evaluation['lifecycle_stage'] ?? 'TRACKING',
                'message' => $displayEval['message'] ?? '',
                'baseline_stage' => $alert['baseline_stage'] ?? 'TRACKING',
                'stage_notifications' => $stageNotifications,
                'stage_sent' => $stageSentCount,
                'stage_delivery' => self::stageDeliveryState($alert, $visibleStage),
                'sent' => $stageSentCount > 0
            ];
        }

        return $results;
    }

        public static function evaluateSequencedAlert(array $alert, array $market): array
    {
        /*
         * Professional event-state machine:
         * Baseline conditions are context only. They are not treated as prior
         * stages. A stage can advance only from a fresh post-baseline event
         * and only one stage is delivered per worker run.
         */
        $stage1Sent = !empty($alert['stage1_sent_ts']) || !empty($alert['stage1_sent_at']);
        $stage2Sent = !empty($alert['stage2_sent_ts']) || !empty($alert['stage2_sent_at']);
        $stage3Sent = !empty($alert['stage3_sent_ts']) || !empty($alert['stage3_sent_at']);

        $zoneLive = !empty($market['zone_touched']);
        $sweepLive = !empty($market['liquidity_swept']);
        $structureLive = !empty($market['bos_confirmed']) || !empty($market['choch_confirmed']);

        $rawStage = 'TRACKING';
        if ($zoneLive) {
            $rawStage = 'ZONE_APPROACHED';
        }
        if ($stage1Sent && $sweepLive) {
            $rawStage = 'LIQUIDITY_SWEPT';
        }
        if ($stage2Sent && $structureLive) {
            $rawStage = 'STRUCTURE_CONFIRMED';
        }

        $stage = self::highestSentStage($alert);
        $message = 'Tracking active. Waiting for a fresh post-baseline market event.';

        if (!$stage1Sent && $zoneLive) {
            $stage = 'ZONE_APPROACHED';
            $message = 'Stage 1: Fresh zone touch/cross detected after the saved tracking baseline.';
        } elseif ($stage1Sent && !$stage2Sent && $sweepLive) {
            $stage = 'LIQUIDITY_SWEPT';
            $message = 'Stage 2: Fresh liquidity sweep detected after the Stage 1 zone-touch event.';
        } elseif ($stage2Sent && !$stage3Sent && $structureLive) {
            $stage = 'STRUCTURE_CONFIRMED';
            $message = 'Stage 3: Fresh closed-candle BOS/CHoCH with displacement detected after the Stage 2 sweep event.';
        }

        $quality = self::evaluateSignalQuality($alert, $market);
        $candleCloseConfirmed = self::candleCloseConfirmationPassed($alert, $market, $structureLive);
        $finalMarketGuard = self::finalMarketReadinessGuard($alert, $market);

        $ready = $stage3Sent
            && $structureLive
            && $candleCloseConfirmed
            && !empty($quality['passed'])
            && !empty($finalMarketGuard['passed']);

        if ($ready) {
            $stage = 'READY_TO_EXECUTE';
            $message = 'Final setup ready. Fresh staged confirmation sequence has aligned during an open market.';
        } elseif ($stage3Sent && empty($finalMarketGuard['passed'])) {
            $stage = 'STRUCTURE_CONFIRMED';
            $message = $finalMarketGuard['reason'] ?? 'Stage 3 confirmed. Waiting for market reopen and fresh final confirmation.';
        }

        return [
            'ready' => $ready,
            'zone_reached' => $zoneLive,
            'liquidity_swept' => $sweepLive,
            'structure_confirmed' => $structureLive,
            'bos_confirmed' => !empty($market['bos_confirmed']),
            'choch_confirmed' => !empty($market['choch_confirmed']),
            'candle_close_confirmed' => $candleCloseConfirmed,
            'closed_candle_time' => $market['closed_candle_time'] ?? null,
            'quality_passed' => !empty($quality['passed']),
            'quality_score' => $quality['score'] ?? null,
            'quality_reason' => $quality['reason'] ?? '',
            'session_name' => $quality['session'] ?? 'N/A',
            'session_passed' => !empty($quality['session_passed']),
            'market_open' => !empty($market['market_open']),
            'market_status_reason' => $market['market_status_reason'] ?? '',
            'latest_market_open_ts' => $market['latest_market_open_ts'] ?? null,
            'final_market_guard_passed' => !empty($finalMarketGuard['passed']),
            'final_market_guard_reason' => $finalMarketGuard['reason'] ?? '',
            'atr_value' => $quality['atr'] ?? null,
            'volatility_passed' => !empty($quality['volatility_passed']),
            'volatility_status' => $quality['volatility_status'] ?? 'N/A',
            'mtf_passed' => !empty($quality['mtf_passed']),
            'mtf_status' => $quality['mtf_status'] ?? 'N/A',
            'mtf_reason' => $quality['mtf_reason'] ?? '',
            'mtf_score' => $quality['mtf_score'] ?? 'N/A',
            'mtf_weight' => $quality['mtf_weight'] ?? 'N/A',
            'mtf_penalty' => $quality['mtf_penalty'] ?? 'N/A',
            'mtf_strictness' => $quality['mtf_strictness'] ?? 'N/A',
            'candle_close_passed' => !empty($quality['candle_close_passed']),
            'candle_close_status' => $quality['candle_close_status'] ?? 'N/A',
            'candle_close_reason' => $quality['candle_close_reason'] ?? '',
            'zone_evidence' => $market['zone_evidence'] ?? '',
            'sweep_evidence' => $market['sweep_evidence'] ?? '',
            'structure_evidence' => $market['structure_evidence'] ?? '',
            'raw_lifecycle_stage' => $rawStage,
            'lifecycle_stage' => $stage,
            'message' => $message,
            'event_guard' => $market['stage_guard_note'] ?? 'event_state_machine',
            'zone_event_ts' => $market['zone_event_ts'] ?? null,
            'sweep_event_ts' => $market['sweep_event_ts'] ?? null,
            'structure_event_ts' => $market['structure_event_ts'] ?? null,
        ];
    }




    private static function finalMarketReadinessGuard(array $alert, array $market): array
    {
        if (self::developerSimulationEnabled($alert)) {
            return ['passed' => true, 'reason' => 'Developer test mode market guard bypassed.'];
        }

        if (!self::settingEnabled('market_open_guard_enabled', true)) {
            return ['passed' => true, 'reason' => 'Market-open guard disabled by setting.'];
        }

        $symbol = (string)($alert['symbol'] ?? ($market['symbol'] ?? ''));
        $assetType = self::alertAssetType($symbol);

        if ($assetType === 'CRYPTO') {
            return ['passed' => true, 'reason' => 'Crypto market is treated as continuously open.'];
        }

        $status = self::marketOpenStatus($symbol, time());
        if (empty($status['open'])) {
            return [
                'passed' => false,
                'reason' => $status['reason'] ?? 'Market is closed. Waiting for market reopen before final setup delivery.',
                'market_status' => $status,
            ];
        }

        $stage3Ts = (int)($alert['stage3_event_ts'] ?? 0);
        if ($stage3Ts <= 0) $stage3Ts = (int)($alert['stage3_sent_ts'] ?? 0);
        if ($stage3Ts <= 0) $stage3Ts = self::alertTimeToTs($alert['stage3_sent_at'] ?? null);

        $latestOpenTs = (int)($status['last_open_ts'] ?? 0);
        $latestClosedTs = (int)($market['latest_closed_candle_ts'] ?? 0);
        $liveQuoteTs = (int)($market['live_quote_ts'] ?? 0);
        $priceSource = (string)($market['price_source'] ?? '');

        if (self::settingEnabled('final_requires_post_reopen_candle', true) && $stage3Ts > 0 && $latestOpenTs > $stage3Ts) {
            if ($latestClosedTs <= $latestOpenTs) {
                return [
                    'passed' => false,
                    'reason' => 'Stage 3 was confirmed before the last market closure. Waiting for a fresh closed confirmation candle after market reopen.',
                    'market_status' => $status,
                ];
            }
        }

        if (self::settingEnabled('final_requires_fresh_live_quote', true)) {
            if ($priceSource === 'latest_saved_candle_close' || $liveQuoteTs <= 0) {
                return [
                    'passed' => false,
                    'reason' => 'Waiting for a fresh live quote during open market hours before final setup delivery.',
                    'market_status' => $status,
                ];
            }

            if ($latestOpenTs > 0 && $liveQuoteTs < $latestOpenTs) {
                return [
                    'passed' => false,
                    'reason' => 'Latest live quote is from before the last market reopen. Waiting for a fresh live quote.',
                    'market_status' => $status,
                ];
            }
        }

        return [
            'passed' => true,
            'reason' => 'Market is open with fresh final-confirmation data.',
            'market_status' => $status,
        ];
    }

    private static function marketOpenStatus(string $symbol, ?int $timestamp = null): array
    {
        $timestamp = $timestamp ?: time();
        $assetType = self::alertAssetType($symbol);

        if ($assetType === 'CRYPTO') {
            return [
                'open' => true,
                'session' => 'CRYPTO_24_7',
                'reason' => 'Crypto market is treated as continuously open.',
                'last_open_ts' => $timestamp,
                'next_open_ts' => null,
                'asset_type' => $assetType,
            ];
        }

        $tzName = (string)(self::getSetting('market_open_guard_timezone') ?: 'America/New_York');
        try {
            $tz = new DateTimeZone($tzName);
        } catch (Throwable $e) {
            $tz = new DateTimeZone('America/New_York');
        }

        $dt = (new DateTimeImmutable('@' . $timestamp))->setTimezone($tz);
        $dow = (int)$dt->format('N'); // 1=Mon ... 7=Sun
        $minutes = ((int)$dt->format('G') * 60) + (int)$dt->format('i');
        $closeMinutes = 17 * 60;
        $open = true;
        $reason = 'Market is open.';
        $nextOpenTs = null;

        if ($dow === 6) {
            $open = false;
            $reason = 'Weekend market closure. Waiting for Sunday 5:00 PM New York reopen before final setup delivery.';
        } elseif ($dow === 5 && $minutes >= $closeMinutes) {
            $open = false;
            $reason = 'Friday market close has passed. Waiting for Sunday 5:00 PM New York reopen before final setup delivery.';
        } elseif ($dow === 7 && $minutes < $closeMinutes) {
            $open = false;
            $reason = 'Sunday pre-open period. Waiting for Sunday 5:00 PM New York reopen before final setup delivery.';
        }

        if (!$open) {
            $daysUntilSunday = (7 - $dow + 7) % 7;
            if ($dow === 7 && $minutes < $closeMinutes) {
                $daysUntilSunday = 0;
            } elseif ($dow === 7 && $minutes >= $closeMinutes) {
                $daysUntilSunday = 7;
            }
            $nextOpen = $dt->modify('+' . $daysUntilSunday . ' days')->setTime(17, 0, 0);
            if ($nextOpen->getTimestamp() <= $timestamp) {
                $nextOpen = $nextOpen->modify('+7 days');
            }
            $nextOpenTs = $nextOpen->getTimestamp();
        }

        $lastOpen = self::lastWeeklyMarketOpenTs($timestamp, $tz);

        return [
            'open' => $open,
            'session' => $open ? 'OPEN' : 'CLOSED',
            'reason' => $reason,
            'last_open_ts' => $lastOpen,
            'next_open_ts' => $nextOpenTs,
            'asset_type' => $assetType,
            'timezone' => $tz->getName(),
        ];
    }

    private static function lastWeeklyMarketOpenTs(int $timestamp, DateTimeZone $tz): int
    {
        $dt = (new DateTimeImmutable('@' . $timestamp))->setTimezone($tz);
        $dow = (int)$dt->format('N');
        $minutes = ((int)$dt->format('G') * 60) + (int)$dt->format('i');
        $daysBackToSunday = $dow % 7;
        $candidate = $dt->modify('-' . $daysBackToSunday . ' days')->setTime(17, 0, 0);

        if ($dow === 7 && $minutes < (17 * 60)) {
            $candidate = $candidate->modify('-7 days');
        }

        if ($candidate->getTimestamp() > $timestamp) {
            $candidate = $candidate->modify('-7 days');
        }

        return $candidate->getTimestamp();
    }

    private static function marketWasOpenAt(string $symbol, int $timestamp): bool
    {
        if ($timestamp <= 0) {
            return false;
        }
        return !empty(self::marketOpenStatus($symbol, $timestamp)['open']);
    }

    private static function candleOpenAllowedForSymbol(string $symbol, int $openTs, string $timeframe = ''): bool
    {
        if ($openTs <= 0) {
            return false;
        }

        if (!self::settingEnabled('prevent_weekend_candle_usage', true)) {
            return true;
        }

        return self::marketWasOpenAt($symbol, $openTs);
    }

    private static function filterTradableCandles(string $symbol, array $candles): array
    {
        if (!self::settingEnabled('prevent_weekend_candle_usage', true)) {
            return $candles;
        }

        if (self::alertAssetType($symbol) === 'CRYPTO') {
            return $candles;
        }

        $filtered = [];
        foreach ($candles as $candle) {
            $openTs = (int)($candle['__open_ts'] ?? self::alertTimeToTs($candle['open_time'] ?? null));
            if ($openTs > 0 && self::marketWasOpenAt($symbol, $openTs)) {
                $filtered[] = $candle;
            }
        }

        return $filtered;
    }

    private static function finalExecutionZoneAssessment(array $alert, array $execution): array
    {
        $entry = self::num($execution['entry_price'] ?? null);
        $zoneLow = self::num($alert['entry_low'] ?? null);
        $zoneHigh = self::num($alert['entry_high'] ?? null);

        if ($entry === null || $zoneLow === null || $zoneHigh === null) {
            return [
                'checked' => false,
                'outside_tracked_zone' => false,
                'reason' => 'Zone integrity could not be checked because entry or tracked zone is missing.',
            ];
        }

        $low = min($zoneLow, $zoneHigh);
        $high = max($zoneLow, $zoneHigh);
        $width = max(abs($high - $low), 0.00000001);
        $bufferPercent = (float)(self::getSetting('derived_setup_zone_buffer_percent') ?: 10);
        $bufferPercent = max(0.0, min(50.0, $bufferPercent));
        $tolerance = $width * ($bufferPercent / 100.0);
        $inside = $entry >= ($low - $tolerance) && $entry <= ($high + $tolerance);

        return [
            'checked' => true,
            'outside_tracked_zone' => !$inside,
            'entry' => $entry,
            'original_zone_low' => $low,
            'original_zone_high' => $high,
            'zone_width' => $width,
            'tolerance' => $tolerance,
            'buffer_percent' => $bufferPercent,
            'reason' => $inside
                ? 'Dynamic execution remains inside the tracked zone or within allowed refinement tolerance.'
                : 'Dynamic execution is outside the tracked zone beyond the allowed tolerance.',
        ];
    }

    private static function maybeCreateDerivedSuggestedSetup(array $alert, array $market, array $evaluation, array $execution, array $zoneAssessment, array $newsWarning = []): array
    {
        self::ensureSuggestedSetupsTable();

        if (!self::settingEnabled('suggest_derived_setups_enabled', true)) {
            return ['created' => false, 'reason' => 'Suggested setup creation is disabled.'];
        }

        $eligibility = self::derivedSetupEligibility($alert, $market, $evaluation, $execution, $zoneAssessment, $newsWarning);
        if (empty($eligibility['passed'])) {
            return ['created' => false, 'reason' => $eligibility['reason'] ?? 'Derived setup conditions were not satisfied.', 'conditions' => $eligibility];
        }

        $userId = (int)($alert['user_id'] ?? 0);
        $sourceAlertId = (int)($alert['id'] ?? 0);
        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim((string)($alert['symbol'] ?? ''))));
        $side = strtoupper((string)($alert['side'] ?? ''));
        $timeframe = self::normalizeAlertTimeframe((string)($alert['timeframe'] ?? ''));
        $confirmationTf = (string)($market['confirmation_timeframe'] ?? self::confirmationTimeframeForSetup($timeframe));
        $entry = self::num($execution['entry_price'] ?? null);
        $newLow = self::num($execution['entry_low'] ?? null);
        $newHigh = self::num($execution['entry_high'] ?? null);

        if ($newLow === null || $newHigh === null) {
            $width = (float)($zoneAssessment['zone_width'] ?? 0.0001);
            $pad = max($width * 0.15, abs((float)$entry) * 0.00002);
            $newLow = (float)$entry - $pad;
            $newHigh = (float)$entry + $pad;
        }

        $low = min($newLow, $newHigh);
        $high = max($newLow, $newHigh);
        $precisionEntry = number_format((float)$entry, 5, '.', '');
        $precisionLow = number_format((float)$low, 5, '.', '');
        $precisionHigh = number_format((float)$high, 5, '.', '');
        $uid = sha1($userId . '|' . $sourceAlertId . '|' . $symbol . '|' . $side . '|' . $timeframe . '|' . $precisionEntry . '|' . $precisionLow . '|' . $precisionHigh);
        $suggestionUid = substr($uid, 0, 64);

        $cooldownMinutes = (int)(self::getSetting('derived_setup_suggestion_cooldown_minutes') ?: 60);
        $cooldownMinutes = max(5, min(1440, $cooldownMinutes));
        $expiresMinutes = (int)(self::getSetting('derived_setup_suggestion_expiry_minutes') ?: 240);
        $expiresMinutes = max(30, min(4320, $expiresMinutes));
        $maxSuggestions = (int)(self::getSetting('derived_setup_max_suggestions_per_source') ?: 2);
        $maxSuggestions = max(1, min(10, $maxSuggestions));

        try {
            $stmt = db()->prepare("\n                SELECT COUNT(*)\n                FROM institutional_suggested_setups\n                WHERE user_id = ?\n                  AND source_alert_id = ?\n                  AND symbol = ?\n                  AND side = ?\n                  AND status <> 'EXPIRED'\n            ");
            $stmt->execute([$userId, $sourceAlertId, $symbol, $side]);
            if ((int)$stmt->fetchColumn() >= $maxSuggestions) {
                return [
                    'created' => false,
                    'reason' => 'The maximum number of suggested setups for this original setup has already been reached.',
                    'conditions' => $eligibility,
                ];
            }
        } catch (Throwable $e) {
        }

        try {
            $stmt = db()->prepare("\n                SELECT id, suggestion_uid\n                FROM institutional_suggested_setups\n                WHERE user_id = ?\n                  AND source_alert_id = ?\n                  AND symbol = ?\n                  AND side = ?\n                  AND status = 'PENDING'\n                  AND created_at >= DATE_SUB(NOW(), INTERVAL {$cooldownMinutes} MINUTE)\n                ORDER BY id DESC\n                LIMIT 1\n            ");
            $stmt->execute([$userId, $sourceAlertId, $symbol, $side]);
            $existing = $stmt->fetch(PDO::FETCH_ASSOC);
            if ($existing) {
                return [
                    'created' => false,
                    'existing' => true,
                    'id' => (int)$existing['id'],
                    'suggestion_uid' => $existing['suggestion_uid'] ?? null,
                    'reason' => 'A pending suggested setup already exists for this source setup within the cooldown window.',
                    'conditions' => $eligibility,
                ];
            }
        } catch (Throwable $e) {
        }

        $reason = 'A fresh execution candidate formed outside the original tracked zone. The original setup was preserved and this new setup was saved for user review.';
        $conditionsJson = json_encode($eligibility, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $executionJson = json_encode($execution, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $marketJson = json_encode($market, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $evaluationJson = json_encode($evaluation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $expiresAt = date('Y-m-d H:i:s', time() + ($expiresMinutes * 60));
        $qualityScore = self::num($evaluation['quality_score'] ?? null);
        $confidence = self::derivedSetupConfidenceLabel($qualityScore);
        $locationGate = $eligibility['checks']['location_gate'] ?? [];
        $htfLocation = is_array($locationGate['htf'] ?? null) ? $locationGate['htf'] : [];
        $ltfLocation = is_array($locationGate['ltf'] ?? null) ? $locationGate['ltf'] : [];
        $locationGateReason = (string)($locationGate['reason'] ?? 'Premium/discount location gate checked.');

        try {
            $stmt = db()->prepare("\n                INSERT INTO institutional_suggested_setups\n                (suggestion_uid, source_alert_id, user_id, source_setup_key, symbol, timeframe, confirmation_timeframe, style, side,\n                 original_entry_price, original_entry_low, original_entry_high,\n                 suggested_entry_price, suggested_entry_low, suggested_entry_high, stop_loss, tp1, tp2, tp3, current_price,\n                 quality_score, confidence_label, status, reason, htf_location_label, htf_location_score, ltf_location_label, ltf_location_score, location_gate_reason,\n                 conditions_json, execution_snapshot, market_snapshot, evaluation_snapshot, expires_at)\n                VALUES\n                (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n                ON DUPLICATE KEY UPDATE\n                    suggested_entry_price = VALUES(suggested_entry_price),\n                    suggested_entry_low = VALUES(suggested_entry_low),\n                    suggested_entry_high = VALUES(suggested_entry_high),\n                    stop_loss = VALUES(stop_loss),\n                    tp1 = VALUES(tp1),\n                    tp2 = VALUES(tp2),\n                    tp3 = VALUES(tp3),\n                    current_price = VALUES(current_price),\n                    quality_score = VALUES(quality_score),\n                    confidence_label = VALUES(confidence_label),\n                    reason = VALUES(reason),\n                    htf_location_label = VALUES(htf_location_label),\n                    htf_location_score = VALUES(htf_location_score),\n                    ltf_location_label = VALUES(ltf_location_label),\n                    ltf_location_score = VALUES(ltf_location_score),\n                    location_gate_reason = VALUES(location_gate_reason),\n                    conditions_json = VALUES(conditions_json),\n                    execution_snapshot = VALUES(execution_snapshot),\n                    market_snapshot = VALUES(market_snapshot),\n                    evaluation_snapshot = VALUES(evaluation_snapshot),\n                    expires_at = VALUES(expires_at),\n                    updated_at = NOW()\n            ");

            $stmt->execute([
                $suggestionUid,
                $sourceAlertId,
                $userId,
                (string)($alert['setup_key'] ?? ''),
                $symbol,
                $timeframe,
                $confirmationTf,
                (string)($alert['style'] ?? ''),
                $side,
                self::num($alert['entry_price'] ?? null),
                self::num($alert['entry_low'] ?? null),
                self::num($alert['entry_high'] ?? null),
                $entry,
                $low,
                $high,
                self::num($execution['stop_loss'] ?? null),
                self::num($execution['tp1'] ?? null),
                self::num($execution['tp2'] ?? null),
                self::num($execution['tp3'] ?? null),
                self::num($market['current_price'] ?? null),
                $qualityScore,
                $confidence,
                $reason,
                $htfLocation['label'] ?? null,
                self::num($htfLocation['score'] ?? null),
                $ltfLocation['label'] ?? null,
                self::num($ltfLocation['score'] ?? null),
                $locationGateReason,
                $conditionsJson,
                $executionJson,
                $marketJson,
                $evaluationJson,
                $expiresAt,
            ]);

            $id = (int)db()->lastInsertId();
            if ($id <= 0) {
                $find = db()->prepare("SELECT id FROM institutional_suggested_setups WHERE suggestion_uid = ? LIMIT 1");
                $find->execute([$suggestionUid]);
                $id = (int)$find->fetchColumn();
            }

            $notify = self::notifyUserAboutDerivedSuggestedSetup($alert, [
                'id' => $id,
                'suggestion_uid' => $suggestionUid,
                'symbol' => $symbol,
                'side' => $side,
                'timeframe' => $timeframe,
                'confirmation_timeframe' => $confirmationTf,
                'original_entry_low' => self::num($alert['entry_low'] ?? null),
                'original_entry_high' => self::num($alert['entry_high'] ?? null),
                'suggested_entry_price' => $entry,
                'suggested_entry_low' => $low,
                'suggested_entry_high' => $high,
                'stop_loss' => self::num($execution['stop_loss'] ?? null),
                'tp1' => self::num($execution['tp1'] ?? null),
                'quality_score' => $qualityScore,
                'confidence_label' => $confidence,
                'reason' => $reason,
                'htf_location_label' => $htfLocation['label'] ?? null,
                'htf_location_score' => self::num($htfLocation['score'] ?? null),
                'ltf_location_label' => $ltfLocation['label'] ?? null,
                'ltf_location_score' => self::num($ltfLocation['score'] ?? null),
                'location_gate_reason' => $locationGateReason,
                'expires_at' => $expiresAt,
            ], $eligibility);

            return [
                'created' => true,
                'id' => $id,
                'suggestion_uid' => $suggestionUid,
                'expires_at' => $expiresAt,
                'notification' => $notify,
                'conditions' => $eligibility,
            ];
        } catch (Throwable $e) {
            return ['created' => false, 'reason' => 'Suggested setup save failed: ' . $e->getMessage(), 'conditions' => $eligibility];
        }
    }

    private static function notifyUserAboutDerivedSuggestedSetup(array $alert, array $suggestion, array $eligibility = []): array
    {
        $result = ['email' => 'not_configured', 'telegram' => 'not_configured'];
        $suggestionId = (int)($suggestion['id'] ?? 0);
        $symbol = strtoupper((string)($suggestion['symbol'] ?? ($alert['symbol'] ?? '')));
        $side = strtoupper((string)($suggestion['side'] ?? ($alert['side'] ?? '')));
        $tf = (string)($suggestion['timeframe'] ?? ($alert['timeframe'] ?? ''));
        $confirmTf = (string)($suggestion['confirmation_timeframe'] ?? '');
        $reviewUrl = self::publicAppUrl('suggested_setups.php');
        $setupLabel = trim($symbol . ' ' . $side);
        $subject = 'New Suggested Setup Available - ' . $setupLabel;

        $originalZone = self::alertFormatPrice($suggestion['original_entry_low'] ?? null, $symbol, 'N/A') . ' - ' . self::alertFormatPrice($suggestion['original_entry_high'] ?? null, $symbol, 'N/A');
        $newZone = self::alertFormatPrice($suggestion['suggested_entry_low'] ?? null, $symbol, 'N/A') . ' - ' . self::alertFormatPrice($suggestion['suggested_entry_high'] ?? null, $symbol, 'N/A');
        $entry = self::alertFormatPrice($suggestion['suggested_entry_price'] ?? null, $symbol, 'N/A');
        $stop = self::alertFormatPrice($suggestion['stop_loss'] ?? null, $symbol, 'N/A');
        $tp1 = self::alertFormatPrice($suggestion['tp1'] ?? null, $symbol, 'N/A');
        $quality = self::alertDisplayValue($suggestion['quality_score'] ?? null, 'N/A');
        $confidence = self::alertDisplayValue($suggestion['confidence_label'] ?? null, 'Review');
        $htf = trim((string)($suggestion['htf_location_label'] ?? 'N/A'));
        $ltf = trim((string)($suggestion['ltf_location_label'] ?? 'N/A'));
        $htfScore = self::alertDisplayValue($suggestion['htf_location_score'] ?? null, 'N/A');
        $ltfScore = self::alertDisplayValue($suggestion['ltf_location_score'] ?? null, 'N/A');
        $locationReason = self::alertDisplayValue($suggestion['location_gate_reason'] ?? ($eligibility['checks']['location_gate']['reason'] ?? ''), 'Location gate passed.');

        $plainReason = 'The final alert for the original tracked setup was not sent because the recalculated execution entry no longer belongs to the originally tracked institutional zone. The original setup was preserved and this new setup is available for review.';

        $body = '<h2>New Suggested Setup Available</h2>'
            . '<p><strong>' . htmlspecialchars($setupLabel, ENT_QUOTES, 'UTF-8') . '</strong> • Setup ' . htmlspecialchars($tf, ENT_QUOTES, 'UTF-8') . ($confirmTf ? ' • Confirm ' . htmlspecialchars($confirmTf, ENT_QUOTES, 'UTF-8') : '') . '</p>'
            . '<p>' . htmlspecialchars($plainReason, ENT_QUOTES, 'UTF-8') . '</p>'
            . '<table cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;max-width:680px">'
            . '<tr><td><strong>Original tracked zone</strong></td><td>' . htmlspecialchars($originalZone, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>New suggested zone</strong></td><td>' . htmlspecialchars($newZone, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>Suggested entry</strong></td><td>' . htmlspecialchars($entry, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>Stop loss</strong></td><td>' . htmlspecialchars($stop, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>TP1</strong></td><td>' . htmlspecialchars($tp1, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>Quality</strong></td><td>' . htmlspecialchars((string)$quality . ' • ' . $confidence, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>HTF location</strong></td><td>' . htmlspecialchars($htf . ' • Score: ' . $htfScore, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>LTF location</strong></td><td>' . htmlspecialchars($ltf . ' • Score: ' . $ltfScore, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '<tr><td><strong>Location rule</strong></td><td>' . htmlspecialchars($locationReason, ENT_QUOTES, 'UTF-8') . '</td></tr>'
            . '</table>'
            . '<p><strong>Action required:</strong> review the suggested setup in your dashboard. It will not be tracked unless you approve it.</p>'
            . ($reviewUrl ? '<p><a href="' . htmlspecialchars($reviewUrl, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#111827;color:#ffffff;padding:10px 14px;border-radius:10px;text-decoration:none">Review Suggested Setup</a></p>' : '');

        $telegram = '<b>New Suggested Setup Available</b>'
            . "\n\n<b>" . htmlspecialchars($setupLabel, ENT_QUOTES, 'UTF-8') . "</b> • Setup " . htmlspecialchars($tf, ENT_QUOTES, 'UTF-8') . ($confirmTf ? ' • Confirm ' . htmlspecialchars($confirmTf, ENT_QUOTES, 'UTF-8') : '')
            . "\n\nThe final alert for the original tracked zone was not sent because the new execution entry no longer belongs to that zone."
            . "\n\n<b>Original Zone:</b> " . htmlspecialchars($originalZone, ENT_QUOTES, 'UTF-8')
            . "\n<b>Suggested Zone:</b> " . htmlspecialchars($newZone, ENT_QUOTES, 'UTF-8')
            . "\n<b>Suggested Entry:</b> " . htmlspecialchars($entry, ENT_QUOTES, 'UTF-8')
            . "\n<b>SL:</b> " . htmlspecialchars($stop, ENT_QUOTES, 'UTF-8')
            . "\n<b>TP1:</b> " . htmlspecialchars($tp1, ENT_QUOTES, 'UTF-8')
            . "\n<b>Quality:</b> " . htmlspecialchars((string)$quality . ' • ' . $confidence, ENT_QUOTES, 'UTF-8')
            . "\n<b>HTF Location:</b> " . htmlspecialchars($htf . ' • ' . $htfScore, ENT_QUOTES, 'UTF-8')
            . "\n<b>LTF Location:</b> " . htmlspecialchars($ltf . ' • ' . $ltfScore, ENT_QUOTES, 'UTF-8')
            . "\n\nReview it in your dashboard before tracking. This is not a final execution alert.";

        if (self::settingEnabled('derived_setup_notify_email', true) && !empty($alert['email']) && (!isset($alert['email_enabled']) || !empty($alert['email_enabled']))) {
            $sent = self::sendEmail((string)$alert['email'], $subject, $body);
            $result['email'] = $sent ? 'sent' : 'failed';
            self::logAlert((int)($alert['id'] ?? 0), (int)($alert['user_id'] ?? 0), 'suggested_setup_email', $sent ? 'sent' : 'failed', $body, null, 'DERIVED_SETUP');
        }

        if (self::settingEnabled('derived_setup_notify_telegram', true) && !empty($alert['telegram_chat_id']) && (!isset($alert['telegram_enabled']) || !empty($alert['telegram_enabled']))) {
            $sent = self::sendTelegram($telegram, (string)$alert['telegram_chat_id']);
            $result['telegram'] = $sent ? 'sent' : 'failed';
            self::logAlert((int)($alert['id'] ?? 0), (int)($alert['user_id'] ?? 0), 'suggested_setup_telegram', $sent ? 'sent' : 'failed', $telegram, null, 'DERIVED_SETUP');
        }

        if ($suggestionId > 0) {
            try {
                $emailSent = $result['email'] === 'sent' ? 1 : 0;
                $telegramSent = $result['telegram'] === 'sent' ? 1 : 0;
                db()->prepare("\n                    UPDATE institutional_suggested_setups\n                    SET notification_attempted_at = NOW(),\n                        notification_count = COALESCE(notification_count,0) + 1,\n                        email_notified_at = CASE WHEN ? = 1 THEN NOW() ELSE email_notified_at END,\n                        telegram_notified_at = CASE WHEN ? = 1 THEN NOW() ELSE telegram_notified_at END,\n                        notification_sent_at = CASE WHEN (? = 1 OR ? = 1) THEN NOW() ELSE notification_sent_at END,\n                        updated_at = NOW()\n                    WHERE id = ?\n                ")->execute([$emailSent, $telegramSent, $emailSent, $telegramSent, $suggestionId]);
            } catch (Throwable $e) {
            }
        }

        return $result;
    }

    private static function publicAppUrl(string $path = ''): string
    {
        $base = trim((string)(self::getSetting('app_base_url') ?: self::getSetting('site_url') ?: self::getSetting('platform_url') ?: self::getSetting('dashboard_url') ?: ''));
        $path = ltrim($path, '/');
        if ($base === '') {
            return $path;
        }
        return rtrim($base, '/') . '/' . $path;
    }

    private static function derivedSetupEligibility(array $alert, array $market, array $evaluation, array $execution, array $zoneAssessment, array $newsWarning = []): array
    {
        $conditions = [];
        $fail = [];

        $side = strtoupper((string)($alert['side'] ?? ''));
        $entry = self::num($execution['entry_price'] ?? null);
        $stop = self::num($execution['stop_loss'] ?? null);
        $tp1 = self::num($execution['tp1'] ?? null);

        /*
         * Suggested setup rules are admin-controlled through system_settings.
         * Defaults remain strict because a derived setup should be suggested
         * only when it represents a clean, institutionally valid new setup.
         */
        $requireSameDirection = self::settingEnabled('derived_setup_require_same_direction', true);
        $requireOutsideZone = self::settingEnabled('derived_setup_require_outside_original_zone', true);
        $requireMarketOpen = self::settingEnabled('derived_setup_require_market_open', true);
        $requireFreshQuote = self::settingEnabled('derived_setup_require_fresh_quote', true);
        $requireStructure = self::settingEnabled('derived_setup_require_structure_confirmation', true);
        $requireCandle = self::settingEnabled('derived_setup_require_candle_close_confirmation', true);
        $requireNewsAllowed = self::settingEnabled('derived_setup_require_news_preference', true);
        $requireCleanPrices = self::settingEnabled('derived_setup_require_clean_execution_prices', true);
        $requireGeometry = self::settingEnabled('derived_setup_require_execution_geometry', true);

        $marketOpen = !empty($evaluation['market_open']) || !empty($market['market_open']);
        $structure = !empty($evaluation['structure_confirmed']) || !empty($market['bos_confirmed']) || !empty($market['choch_confirmed']);
        $candle = !empty($evaluation['candle_close_confirmed']) || !empty($evaluation['candle_close_passed']);
        $qualityScore = (float)($evaluation['quality_score'] ?? 0);
        $minimumScore = (float)(self::getSetting('derived_setup_min_quality_score') ?: self::getSetting('minimum_quality_score') ?: 65);
        $outside = !empty($zoneAssessment['outside_tracked_zone']);

        $liveQuoteTs = (int)($market['live_quote_ts'] ?? 0);
        $priceSource = (string)($market['price_source'] ?? '');
        $freshQuoteMaxAge = (int)(self::getSetting('derived_setup_fresh_quote_max_age_seconds') ?: 300);
        $freshQuoteMaxAge = max(60, min(3600, $freshQuoteMaxAge));
        $freshQuote = $liveQuoteTs > 0
            && (time() - $liveQuoteTs) <= $freshQuoteMaxAge
            && $priceSource !== 'latest_saved_candle_close';

        $locationGate = self::derivedSetupLocationGate($side, $alert, $market, $evaluation, $execution);

        $sameDirectionOk = in_array($side, ['BUY', 'SELL'], true);
        $cleanPricesOk = $entry !== null && $stop !== null && $tp1 !== null;

        $geometryOk = false;
        if ($cleanPricesOk) {
            $geometryOk = $side === 'BUY'
                ? ($stop < $entry && $tp1 > $entry)
                : ($stop > $entry && $tp1 < $entry);
        }

        $conditions['admin_rules'] = [
            'require_same_direction' => $requireSameDirection,
            'require_outside_original_zone' => $requireOutsideZone,
            'require_market_open' => $requireMarketOpen,
            'require_fresh_quote' => $requireFreshQuote,
            'fresh_quote_max_age_seconds' => $freshQuoteMaxAge,
            'require_structure_confirmation' => $requireStructure,
            'require_candle_close_confirmation' => $requireCandle,
            'require_news_preference' => $requireNewsAllowed,
            'require_clean_execution_prices' => $requireCleanPrices,
            'require_execution_geometry' => $requireGeometry,
        ];
        $conditions['same_direction'] = $sameDirectionOk;
        $conditions['entry_outside_original_zone'] = $outside;
        $conditions['market_open'] = $marketOpen;
        $conditions['fresh_live_quote'] = $freshQuote;
        $conditions['live_quote_ts'] = $liveQuoteTs;
        $conditions['price_source'] = $priceSource;
        $conditions['structure_confirmed'] = $structure;
        $conditions['candle_close_confirmed'] = $candle;
        $conditions['quality_score'] = $qualityScore;
        $conditions['minimum_quality_score'] = $minimumScore;
        $conditions['quality_passed'] = $qualityScore >= $minimumScore;
        $conditions['news_preference_allowed'] = self::newsPreferenceAllowsFinal($alert, $newsWarning);
        $conditions['has_clean_execution_prices'] = $cleanPricesOk;
        $conditions['execution_geometry_valid'] = $geometryOk;
        $conditions['location_gate'] = $locationGate;
        $conditions['location_gate_passed'] = !empty($locationGate['passed']);
        $conditions['htf_location_passed'] = !empty($locationGate['htf']['passed']);
        $conditions['ltf_location_passed'] = !empty($locationGate['ltf']['passed']);

        if ($requireSameDirection && !$conditions['same_direction']) $fail[] = 'Direction is not BUY or SELL.';
        if ($requireOutsideZone && !$conditions['entry_outside_original_zone']) $fail[] = 'Entry is not outside the original zone.';
        if ($requireMarketOpen && !$conditions['market_open']) $fail[] = 'Market is not open.';
        if ($requireFreshQuote && !$conditions['fresh_live_quote']) $fail[] = 'Fresh live quote is missing or too old for a new suggested setup.';
        if ($requireStructure && !$conditions['structure_confirmed']) $fail[] = 'Fresh structure confirmation is missing.';
        if ($requireCandle && !$conditions['candle_close_confirmed']) $fail[] = 'Fresh candle-close confirmation is missing.';
        if (!$conditions['quality_passed']) $fail[] = 'Quality score is below the required threshold.';
        if ($requireNewsAllowed && !$conditions['news_preference_allowed']) $fail[] = 'News preference does not allow a new suggestion now.';
        if ($requireCleanPrices && !$conditions['has_clean_execution_prices']) $fail[] = 'Entry, stop loss, or TP1 is missing.';
        if ($requireGeometry && !$conditions['execution_geometry_valid']) $fail[] = 'Execution geometry is invalid for the setup direction.';
        if (!$conditions['location_gate_passed']) $fail[] = $locationGate['reason'] ?? 'Premium/discount location gate did not pass.';

        return [
            'passed' => empty($fail),
            'reason' => empty($fail) ? 'Derived setup candidate passed all admin-controlled protection checks, including HTF/LTF institutional location quality.' : implode(' ', $fail),
            'checks' => $conditions,
        ];
    }

    private static function derivedSetupLocationGate(string $side, array $alert, array $market, array $evaluation, array $execution): array
    {
        $side = strtoupper($side);
        $requiredHtf = self::settingEnabled('derived_setup_require_htf_location', true);
        $requiredLtf = self::settingEnabled('derived_setup_require_ltf_location', true);
        $allowMidrange = self::settingEnabled('derived_setup_allow_midrange_continuation', false);
        $minScore = (float)(self::getSetting('derived_setup_min_location_score') ?: 65);
        $minScore = max(0.0, min(100.0, $minScore));

        $payloads = self::derivedSetupLocationPayloads($alert, $market, $evaluation, $execution);
        $htf = self::deriveLocationSignal($side, $payloads['htf'], 'HTF', $minScore, $requiredHtf, $allowMidrange);
        $ltf = self::deriveLocationSignal($side, $payloads['ltf'], 'LTF', $minScore, $requiredLtf, $allowMidrange);

        $passed = (!empty($htf['passed']) || !$requiredHtf) && (!empty($ltf['passed']) || !$requiredLtf);
        $reasonParts = [];
        if (!$passed) {
            if ($requiredHtf && empty($htf['passed'])) {
                $reasonParts[] = $htf['reason'] ?? 'HTF location did not pass.';
            }
            if ($requiredLtf && empty($ltf['passed'])) {
                $reasonParts[] = $ltf['reason'] ?? 'LTF location did not pass.';
            }
        }

        return [
            'passed' => $passed,
            'required_htf' => $requiredHtf,
            'required_ltf' => $requiredLtf,
            'allow_midrange_continuation' => $allowMidrange,
            'min_location_score' => $minScore,
            'htf' => $htf,
            'ltf' => $ltf,
            'reason' => $passed
                ? 'Premium/discount location is acceptable for the suggested setup direction.'
                : implode(' ', array_filter($reasonParts)),
        ];
    }

    private static function derivedSetupLocationPayloads(array $alert, array $market, array $evaluation, array $execution): array
    {
        $scenario = json_decode((string)($alert['scenario_snapshot'] ?? ''), true);
        if (!is_array($scenario)) {
            $scenario = [];
        }

        $basePayloads = [$execution, $evaluation, $market, $scenario, $alert];

        $htfPayloads = [];
        $ltfPayloads = [];
        foreach ($basePayloads as $payload) {
            if (!is_array($payload)) {
                continue;
            }
            $htfPayloads[] = $payload['htf_location'] ?? [];
            $htfPayloads[] = $payload['higher_timeframe_location'] ?? [];
            $htfPayloads[] = $payload['setup_location'] ?? [];
            $htfPayloads[] = $payload['location_quality'] ?? [];
            $htfPayloads[] = $payload;

            $ltfPayloads[] = $payload['ltf_location'] ?? [];
            $ltfPayloads[] = $payload['lower_timeframe_location'] ?? [];
            $ltfPayloads[] = $payload['execution_location'] ?? [];
            $ltfPayloads[] = $payload['refined_location'] ?? [];
            $ltfPayloads[] = $payload['location_quality'] ?? [];
            $ltfPayloads[] = $payload;
        }

        return [
            'htf' => array_values(array_filter($htfPayloads, 'is_array')),
            'ltf' => array_values(array_filter($ltfPayloads, 'is_array')),
        ];
    }

    private static function deriveLocationSignal(string $side, array $payloads, string $scope, float $minScore, bool $required, bool $allowMidrange): array
    {
        $label = self::findFirstLocationText($payloads, [
            'location_quality_label', 'location_label', 'location_state', 'location_quality_state',
            'premium_discount', 'premium_discount_state', 'pd_label', 'price_location', 'zone_location',
            'execution_location_class', 'location_execution_class', 'label', 'state', 'guidance'
        ]);
        $score = self::findFirstLocationNumber($payloads, [
            'location_quality_score', 'location_score', 'location_percent', 'location_percentage',
            'premium_discount_score', 'score', 'quality_score'
        ]);
        $position = self::findFirstLocationNumber($payloads, ['position', 'pd_position', 'location_position', 'range_position']);

        $text = strtoupper(trim((string)$label));
        $isPremium = self::containsText($text, 'PREMIUM') || self::containsText($text, 'SUPPLY') || self::containsText($text, 'SELL LOCATION');
        $isDiscount = self::containsText($text, 'DISCOUNT') || self::containsText($text, 'DEMAND') || self::containsText($text, 'BUY LOCATION');
        $isMid = self::containsText($text, 'MID') || self::containsText($text, 'EQUILIBRIUM') || self::containsText($text, 'NEUTRAL');

        if ($position !== null) {
            if ($position >= 60) {
                $isPremium = true;
            } elseif ($position <= 40) {
                $isDiscount = true;
            } else {
                $isMid = true;
            }
        }

        $directionOk = $side === 'BUY' ? $isDiscount : ($side === 'SELL' ? $isPremium : false);
        if (!$directionOk && $allowMidrange && $isMid && $score !== null && $score >= max($minScore, 75)) {
            $directionOk = true;
        }

        $scoreOk = $score === null ? !$required : ((float)$score >= $minScore);
        $hasSignal = $label !== null || $score !== null || $position !== null;
        $passed = (!$required && !$hasSignal) || ($directionOk && $scoreOk);

        $expected = $side === 'BUY' ? 'discount/demand' : 'premium/supply';
        $reason = $passed
            ? $scope . ' location supports the ' . $side . ' idea.'
            : ($hasSignal
                ? $scope . ' location does not meet the required ' . $expected . ' and score threshold.'
                : $scope . ' location data is missing, so the suggested setup was not auto-created.');

        return [
            'scope' => $scope,
            'passed' => $passed,
            'required' => $required,
            'label' => $label,
            'score' => $score,
            'position' => $position,
            'expected_location' => $expected,
            'reason' => $reason,
        ];
    }

    private static function findFirstLocationText(array $payloads, array $keys): ?string
    {
        foreach ($payloads as $payload) {
            $found = self::recursiveFindByKeys($payload, $keys, 'text');
            if ($found !== null && trim((string)$found) !== '') {
                return trim((string)$found);
            }
        }
        return null;
    }

    private static function findFirstLocationNumber(array $payloads, array $keys): ?float
    {
        foreach ($payloads as $payload) {
            $found = self::recursiveFindByKeys($payload, $keys, 'number');
            if ($found !== null && is_numeric($found)) {
                return (float)$found;
            }
        }
        return null;
    }

    private static function recursiveFindByKeys($value, array $keys, string $type, int $depth = 0)
    {
        if ($depth > 5 || !is_array($value)) {
            return null;
        }
        foreach ($value as $key => $item) {
            $keyLower = strtolower((string)$key);
            $matches = false;
            foreach ($keys as $needle) {
                if ($keyLower === strtolower($needle) || strpos($keyLower, strtolower($needle)) !== false) {
                    $matches = true;
                    break;
                }
            }
            if ($matches) {
                if ($type === 'number' && is_numeric($item)) {
                    return $item;
                }
                if ($type === 'text' && !is_array($item) && trim((string)$item) !== '') {
                    return (string)$item;
                }
                if (is_array($item)) {
                    $nested = self::recursiveFindByKeys($item, $keys, $type, $depth + 1);
                    if ($nested !== null) {
                        return $nested;
                    }
                }
            }
        }
        foreach ($value as $item) {
            if (is_array($item)) {
                $nested = self::recursiveFindByKeys($item, $keys, $type, $depth + 1);
                if ($nested !== null) {
                    return $nested;
                }
            }
        }
        return null;
    }

    private static function derivedSetupConfidenceLabel($qualityScore): string
    {
        $score = is_numeric($qualityScore) ? (float)$qualityScore : 0.0;
        if ($score >= 85) return 'High conviction';
        if ($score >= 70) return 'Qualified';
        return 'Review carefully';
    }

    private static function sanitizeFinalExecutionPlan(array $alert, array $execution): array
    {
        $entry = self::num($execution['entry_price'] ?? null);
        $zoneLow = self::num($alert['entry_low'] ?? null);
        $zoneHigh = self::num($alert['entry_high'] ?? null);

        if ($entry === null || $zoneLow === null || $zoneHigh === null) {
            return $execution;
        }

        $low = min($zoneLow, $zoneHigh);
        $high = max($zoneLow, $zoneHigh);
        $width = max(abs($high - $low), 0.00000001);
        $tolerance = $width * 0.10;

        if ($entry < ($low - $tolerance) || $entry > ($high + $tolerance)) {
            $execution['entry_price'] = $alert['entry_price'] ?? null;
            $execution['entry_low'] = $alert['entry_low'] ?? null;
            $execution['entry_high'] = $alert['entry_high'] ?? null;
            $execution['stop_loss'] = $alert['stop_loss'] ?? null;
            $execution['tp1'] = $alert['tp1'] ?? null;
            $execution['tp2'] = $alert['tp2'] ?? null;
            $execution['tp3'] = $alert['tp3'] ?? null;
            $execution['dynamic'] = false;
            $execution['source'] = 'stored_tracked_zone_fallback';
            $execution['reason'] = 'Dynamic execution was outside the originally tracked zone, so the stored tracked-zone execution plan was preserved.';
        }

        return $execution;
    }

        private static function userAllowsFinalSetup(array $alert): bool
    {
        return (int)($alert['notify_final_setup'] ?? 1) === 1;
    }

    private static function timeframeExpiryMinutes(string $timeframe): int
    {
        $tf = strtolower(trim($timeframe));

        $map = [
            '1m' => 120,
            '3m' => 180,
            '5m' => 240,
            '15m' => 360,
            '30m' => 720,
            '1h' => 1440,
            '2h' => 2160,
            '4h' => 4320,
            '1d' => 10080,
            '1w' => 20160,
        ];

        if (isset($map[$tf])) {
            return $map[$tf];
        }

        if (preg_match('/^(\d+)\s*m$/', $tf, $m)) {
            return max(120, (int)$m[1] * 24);
        }

        if (preg_match('/^(\d+)\s*h$/', $tf, $m)) {
            return max(720, (int)$m[1] * 24 * 60);
        }

        return 1440;
    }

    private static function alertExpired(array $alert): bool
    {
        if (!empty($alert['expired_at'])) {
            return true;
        }

        if ((int)($alert['auto_expire_enabled'] ?? 0) !== 1) {
            return false;
        }

        if (empty($alert['expires_at'])) {
            return false;
        }

        $expiresAt = strtotime((string)$alert['expires_at']);

        return $expiresAt ? time() >= $expiresAt : false;
    }

    private static function expireAlert(int $alertId, string $reason): void
    {
        if ($alertId <= 0) {
            return;
        }

        try {
            db()->prepare("
                UPDATE institutional_alerts
                SET is_active = 0,
                    expired_at = COALESCE(expired_at, NOW()),
                    expiration_reason = ?,
                    lifecycle_stage = 'EXPIRED',
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([$reason, $alertId]);

            self::logAlert($alertId, 0, 'system', 'expired', $reason, null, 'EXPIRED');
        } catch (Throwable $e) {
        }
    }

    private static function newsPreferenceAllowsFinal(array $alert, array $newsWarning): bool
    {
        /*
        |--------------------------------------------------------------------------
        | Advisory-first safety
        |--------------------------------------------------------------------------
        | News should NOT accidentally stop all alerts. It only blocks the final
        | execution notification when the user explicitly enabled news caution
        | AND explicitly disabled the matching impact category.
        |--------------------------------------------------------------------------
        */

        if (self::developerSimulationEnabled($alert)) {
            return true;
        }

        if ((int)($alert['avoid_news_trades'] ?? 0) !== 1) {
            return true;
        }

        $risk = strtoupper((string)($newsWarning['risk'] ?? 'LOW'));

        if (in_array($risk, ['HIGH', 'CRITICAL', 'EXTREME'], true)) {
            return (int)($alert['trade_high_impact_news'] ?? 1) === 1;
        }

        if ($risk === 'MEDIUM') {
            return (int)($alert['trade_medium_impact_news'] ?? 1) === 1;
        }

        return true;
    }

    private static function finalPreferenceBlockReason(array $alert, array $newsWarning): string
    {
        if (!self::userAllowsFinalSetup($alert)) {
            return 'User disabled final setup notifications.';
        }

        $risk = strtoupper((string)($newsWarning['risk'] ?? 'LOW'));
        $title = trim((string)($newsWarning['title'] ?? 'news event'));
        $countdown = trim((string)($newsWarning['countdown'] ?? ''));

        if (in_array($risk, ['HIGH', 'CRITICAL', 'EXTREME'], true)) {
            return 'Final alert blocked by user preference: high-impact news risk' . ($title ? ' (' . $title . ')' : '') . ($countdown ? ' ' . $countdown : '') . '.';
        }

        if ($risk === 'MEDIUM') {
            return 'Final alert blocked by user preference: medium-impact news risk' . ($title ? ' (' . $title . ')' : '') . ($countdown ? ' ' . $countdown : '') . '.';
        }

        return 'Final alert blocked by user preference.';
    }


        private static function alertTimeToTs($value): int
    {
        if ($value === null || $value === '') {
            return 0;
        }

        if (is_numeric($value)) {
            $ts = (int)$value;
            return $ts > 20000000000 ? (int)floor($ts / 1000) : $ts;
        }

        $value = trim((string)$value);

        if ($value === '') {
            return 0;
        }

        /*
         * Candle open_time values are stored as UTC-like strings. Parse
         * timezone-less database candle times as UTC so Lagos/server timezone
         * does not shift the alert sequence by one hour.
         */
        try {
            if (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/', $value)) {
                $dt = new DateTimeImmutable(str_replace('T', ' ', $value), new DateTimeZone('UTC'));
                return $dt->getTimestamp();
            }

            if (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$/', $value)) {
                $dt = new DateTimeImmutable(str_replace('T', ' ', $value) . ':00', new DateTimeZone('UTC'));
                return $dt->getTimestamp();
            }

            $dt = new DateTimeImmutable($value);
            return $dt->getTimestamp();
        } catch (Throwable $e) {
            $ts = strtotime($value);
            return $ts ? (int)$ts : 0;
        }
    }

    private static function confirmationTimeframeForSetup(string $setupTimeframe): string
    {
        /*
        |--------------------------------------------------------------------------
        | Setup timeframe vs confirmation timeframe
        |--------------------------------------------------------------------------
        | The setup timeframe gives the institutional location/zone.
        | The confirmation timeframe is used for Stage 1/2 monitoring and Stage 3
        | BOS/CHoCH displacement. This avoids waiting too long on 1H/4H candles.
        |--------------------------------------------------------------------------
        */

        $setupTimeframe = strtolower(trim($setupTimeframe));
        $configured = trim((string)(self::getSetting('alert_confirmation_timeframe_map') ?: ''));

        if ($configured !== '') {
            $json = json_decode($configured, true);

            if (is_array($json) && isset($json[$setupTimeframe])) {
                return strtolower(trim((string)$json[$setupTimeframe]));
            }

            foreach (explode(',', $configured) as $pair) {
                $parts = array_map('trim', explode(':', $pair, 2));
                if (count($parts) === 2 && strtolower($parts[0]) === $setupTimeframe && $parts[1] !== '') {
                    return strtolower($parts[1]);
                }
            }
        }

        $default = [
            '1m' => '1m',
            '3m' => '1m',
            '5m' => '1m',
            '15m' => '5m',
            '30m' => '5m',
            '1h' => '15m',
            '2h' => '15m',
            '4h' => '1h',
            '6h' => '1h',
            '8h' => '1h',
            '12h' => '4h',
            '1d' => '4h',
            '1w' => '1d',
        ];

        return $default[$setupTimeframe] ?? $setupTimeframe;
    }

    private static function lowerTimeframeConfirmationEnabled(): bool
    {
        return self::settingEnabled('alert_use_ltf_confirmation', true);
    }

    private static function sortCandlesByOpenTime(array $candles): array
    {
        usort($candles, function ($a, $b) {
            return self::alertTimeToTs($a['open_time'] ?? '') <=> self::alertTimeToTs($b['open_time'] ?? '');
        });

        return $candles;
    }


    private static function latestCandleOpenTs(array $candles): int
    {
        if (empty($candles)) {
            return 0;
        }

        $latest = end($candles);
        if (!is_array($latest)) {
            return 0;
        }

        return self::alertTimeToTs($latest['open_time'] ?? null) ?: 0;
    }

    private static function candlesAreUsable(array $candles, int $minimum = 8): bool
    {
        return count($candles) >= $minimum;
    }

    private static function normalizeAlertTimeframe(string $timeframe): string
    {
        $tf = strtoupper(str_replace([' ', '_'], '', trim((string)$timeframe)));

        $map = [
            'M1' => '1M', '1MIN' => '1M', '1MINUTE' => '1M', '1M' => '1M',
            'M3' => '3M', '3MIN' => '3M', '3MINUTE' => '3M', '3M' => '3M',
            'M5' => '5M', '5MIN' => '5M', '5MINUTE' => '5M', '5M' => '5M',
            'M15' => '15M', '15MIN' => '15M', '15MINUTE' => '15M', '15M' => '15M',
            'M30' => '30M', '30MIN' => '30M', '30MINUTE' => '30M', '30M' => '30M',
            'H1' => '1H', '1HOUR' => '1H', '1H' => '1H',
            'H2' => '2H', '2HOUR' => '2H', '2H' => '2H',
            'H4' => '4H', '4HOUR' => '4H', '4H' => '4H',
            'D1' => '1D', '1DAY' => '1D', '1D' => '1D',
            'W1' => '1W', '1WEEK' => '1W', '1W' => '1W',
        ];

        return $map[$tf] ?? $tf;
    }


    private static function cryptoBinanceSymbol(string $symbol): string
    {
        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim($symbol)));

        try {
            if (class_exists('SystemConfigService') && method_exists('SystemConfigService', 'getSymbolConfig')) {
                $cfg = SystemConfigService::getSymbolConfig($symbol);
                $providerSymbol = strtoupper(str_replace(['/', '-', '_', ' '], '', (string)($cfg['provider_symbol'] ?? '')));
                if ($providerSymbol !== '') {
                    return $providerSymbol;
                }
            }
        } catch (Throwable $e) {
        }

        if (substr($symbol, -3) === 'USD' && substr($symbol, -4) !== 'USDT') {
            return substr($symbol, 0, -3) . 'USDT';
        }

        return $symbol;
    }

    private static function alertLiveStageQuote(string $symbol): array
    {
        if (!self::settingEnabled('alert_use_live_price_for_stage_1_2', true)) {
            return ['price' => null, 'provider' => null, 'source' => 'disabled'];
        }

        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim($symbol)));
        if ($symbol === '') {
            return ['price' => null, 'provider' => null, 'source' => 'empty_symbol'];
        }

        static $cache = [];
        $assetType = self::alertAssetType($symbol);
        $ttl = $assetType === 'CRYPTO'
            ? 20
            : (int)(self::getSetting('alert_live_quote_ttl_seconds') ?: 60);
        $ttl = max(10, min(180, $ttl));

        if (isset($cache[$symbol]) && (time() - (int)$cache[$symbol]['ts']) <= $ttl) {
            return $cache[$symbol]['quote'];
        }

        $quote = ['price' => null, 'provider' => null, 'source' => 'unavailable'];

        /*
         * Production order for Stage 1/2:
         * 1. LiveQuoteService aggregator
         * 2. Legacy QuoteService
         * 3. Crypto Binance fallback
         */
        if (class_exists('LiveQuoteService')) {
            try {
                $q = LiveQuoteService::getQuote($symbol);
                if (!empty($q['success']) && is_numeric($q['price'] ?? null)) {
                    $quote = [
                        'price' => (float)$q['price'],
                        'provider' => (string)($q['provider'] ?? 'live_quote_service'),
                        'source' => (string)($q['source'] ?? 'live_quote_aggregator'),
                        'provider_symbol' => (string)($q['provider_symbol'] ?? ''),
                        'timestamp' => (string)($q['timestamp'] ?? $q['quote_time'] ?? gmdate('Y-m-d H:i:s')),
                    ];
                    $cache[$symbol] = ['ts' => time(), 'quote' => $quote];
                    return $quote;
                }

                $quote = [
                    'price' => null,
                    'provider' => (string)($q['provider'] ?? 'live_quote_service'),
                    'source' => (string)($q['source'] ?? 'quote_failed'),
                    'error' => (string)($q['error'] ?? 'No live quote returned.'),
                ];
            } catch (Throwable $e) {
                $quote = ['price' => null, 'provider' => 'live_quote_service', 'source' => 'exception', 'error' => $e->getMessage()];
            }
        }

        if (($quote['price'] ?? null) === null && class_exists('QuoteService')) {
            try {
                $q = QuoteService::getLivePrice($symbol);
                if (!empty($q['success']) && is_numeric($q['price'] ?? null)) {
                    $quote = [
                        'price' => (float)$q['price'],
                        'provider' => (string)($q['provider'] ?? 'quote_service'),
                        'source' => (string)($q['source'] ?? 'live_quote'),
                        'provider_symbol' => (string)($q['provider_symbol'] ?? ''),
                        'timestamp' => (string)($q['timestamp'] ?? gmdate('Y-m-d H:i:s')),
                    ];
                    $cache[$symbol] = ['ts' => time(), 'quote' => $quote];
                    return $quote;
                }

                $quote = [
                    'price' => null,
                    'provider' => (string)($q['provider'] ?? 'quote_service'),
                    'source' => (string)($q['source'] ?? 'quote_failed'),
                    'error' => (string)($q['error'] ?? 'No live quote returned.'),
                ];
            } catch (Throwable $e) {
                $quote = ['price' => null, 'provider' => 'quote_service', 'source' => 'exception', 'error' => $e->getMessage()];
            }
        }

        /* Backward-compatible crypto fallback if QuoteService is missing/fails. */
        if (($quote['price'] ?? null) === null && $assetType === 'CRYPTO') {
            $p = self::alertLiveCryptoPrice($symbol);
            if ($p !== null) {
                $quote = ['price' => $p, 'provider' => 'binance', 'source' => 'binance_live_ticker_fallback'];
            }
        }

        $cache[$symbol] = ['ts' => time(), 'quote' => $quote];
        return $quote;
    }

    private static function alertLiveCryptoPrice(string $symbol): ?float
    {
        if (!self::settingEnabled('alert_use_live_crypto_ticker', true)) {
            return null;
        }

        if (self::alertAssetType($symbol) !== 'CRYPTO') {
            return null;
        }

        static $cache = [];
        $providerSymbol = self::cryptoBinanceSymbol($symbol);
        $cacheKey = $providerSymbol;

        if (isset($cache[$cacheKey]) && (time() - $cache[$cacheKey]['ts']) <= 20) {
            return $cache[$cacheKey]['price'];
        }

        if (!function_exists('curl_init')) {
            return null;
        }

        $urls = [
            'https://fapi.binance.com/fapi/v1/ticker/price?symbol=' . rawurlencode($providerSymbol),
            'https://api.binance.com/api/v3/ticker/price?symbol=' . rawurlencode($providerSymbol),
            'https://data-api.binance.vision/api/v3/ticker/price?symbol=' . rawurlencode($providerSymbol),
        ];

        foreach ($urls as $url) {
            try {
                $ch = curl_init($url);
                curl_setopt_array($ch, [
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_CONNECTTIMEOUT => 2,
                    CURLOPT_TIMEOUT => 3,
                    CURLOPT_SSL_VERIFYPEER => true,
                    CURLOPT_HTTPHEADER => ['Accept: application/json'],
                ]);

                $body = curl_exec($ch);
                $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);

                if ($code < 200 || $code >= 300 || !$body) {
                    continue;
                }

                $json = json_decode((string)$body, true);
                $price = self::num($json['price'] ?? null);

                if ($price !== null && $price > 0) {
                    $cache[$cacheKey] = ['ts' => time(), 'price' => $price];
                    return $price;
                }
            } catch (Throwable $e) {
            }
        }

        return null;
    }

    private static function enrichCandlesWithTimes(array $candles, string $timeframe, int $now): array
    {
        $tfSeconds = self::timeframeToSeconds($timeframe);
        $enriched = [];

        foreach ($candles as $candle) {
            if (!is_array($candle)) {
                continue;
            }

            $openTs = self::alertTimeToTs($candle['open_time'] ?? null);
            if (!$openTs) {
                continue;
            }

            $closeTs = $tfSeconds > 0 ? ($openTs + $tfSeconds) : $openTs;
            $candle['__open_ts'] = $openTs;
            $candle['__close_ts'] = $closeTs;
            $candle['__is_closed'] = ($tfSeconds <= 0 || $closeTs <= $now);
            $enriched[] = $candle;
        }

        return $enriched;
    }

    private static function candleValue(array $candle, string $key): ?float
    {
        return self::num($candle[$key] ?? null);
    }

private static function buildMarketState(array $alert): array
    {
        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', (string)($alert['symbol'] ?? '')));
        $setupTimeframe = self::normalizeAlertTimeframe((string)($alert['timeframe'] ?? ''));
        $side = strtoupper((string)($alert['side'] ?? ''));
        $now = time();
        $marketStatus = self::marketOpenStatus($symbol, $now);
        $lastMarketOpenTs = (int)($marketStatus['last_open_ts'] ?? 0);

        $confirmationTimeframe = self::lowerTimeframeConfirmationEnabled()
            ? self::normalizeAlertTimeframe(self::confirmationTimeframeForSetup($setupTimeframe))
            : $setupTimeframe;

        $setupCandles = self::sortCandlesByOpenTime(self::getRecentCandles($symbol, $setupTimeframe, 260));
        $confirmationCandles = $confirmationTimeframe !== $setupTimeframe
            ? self::sortCandlesByOpenTime(self::getRecentCandles($symbol, $confirmationTimeframe, 320))
            : $setupCandles;

        $confirmationTfSeconds = self::timeframeToSeconds($confirmationTimeframe);
        $setupTfSeconds = self::timeframeToSeconds($setupTimeframe);
        $confirmationLatestTs = self::latestCandleOpenTs($confirmationCandles);
        $setupLatestTs = self::latestCandleOpenTs($setupCandles);

        $confirmationFresh = $confirmationLatestTs > 0
            && ($confirmationLatestTs + max(90, $confirmationTfSeconds * 4)) >= $now;

        $setupFresh = $setupLatestTs > 0
            && ($setupLatestTs + max(90, $setupTfSeconds * 4)) >= $now;

        $usingConfirmationTimeframe = self::candlesAreUsable($confirmationCandles, 6) && $confirmationFresh;
        $rawCandles = $usingConfirmationTimeframe ? $confirmationCandles : $setupCandles;
        $scanTimeframe = $usingConfirmationTimeframe ? $confirmationTimeframe : $setupTimeframe;
        $scanTfSeconds = self::timeframeToSeconds($scanTimeframe);

        $candles = self::enrichCandlesWithTimes($rawCandles, $scanTimeframe, $now);
        $candles = self::filterTradableCandles($symbol, $candles);
        $closedCandles = array_values(array_filter($candles, static function ($c) {
            return !empty($c['__is_closed']);
        }));

        $latest = !empty($candles) ? end($candles) : [];
        $latest = is_array($latest) ? $latest : [];
        $latestClosed = !empty($closedCandles) ? end($closedCandles) : [];
        $latestClosed = is_array($latestClosed) ? $latestClosed : [];

        $candlePrice = self::num($latest['close_price'] ?? ($latestClosed['close_price'] ?? null));

        /*
         * Stage 1/2 need the freshest available price. Crypto uses Binance.
         * Forex/metals can use a guarded live quote (TwelveData/FMP) when
         * candles are stale or provider sync is in cooldown. Stage 3/final
         * still require candle structure confirmation.
         */
        $liveStageQuote = self::alertLiveStageQuote($symbol);
        $liveStagePrice = self::num($liveStageQuote['price'] ?? null);
        $liveQuoteTime = $liveStageQuote['quote_time'] ?? ($liveStageQuote['timestamp'] ?? null);
        $liveQuoteTs = self::alertTimeToTs($liveQuoteTime);
        $liveCryptoPrice = self::alertAssetType($symbol) === 'CRYPTO' ? $liveStagePrice : null;
        $price = $liveStagePrice !== null ? $liveStagePrice : $candlePrice;
        $priceSource = $liveStagePrice !== null
            ? (string)($liveStageQuote['source'] ?? 'guarded_live_quote')
            : 'latest_saved_candle_close';

        $closedCandleTime = $latestClosed['open_time'] ?? ($latest['open_time'] ?? null);
        $latestClosedTs = (int)($latestClosed['__open_ts'] ?? 0);

        $entryLow = self::num($alert['entry_low'] ?? null);
        $entryHigh = self::num($alert['entry_high'] ?? null);
        $zoneLow = null;
        $zoneHigh = null;

        if ($entryLow !== null && $entryHigh !== null) {
            $zoneLow = min($entryLow, $entryHigh);
            $zoneHigh = max($entryLow, $entryHigh);
        }

        $trackingStartedTs = (int)($alert['tracking_started_ts'] ?? 0);
        if ($trackingStartedTs <= 0) {
            $trackingStartedTs = self::alertTimeToTs($alert['tracking_started_at'] ?? ($alert['created_at'] ?? null));
        }
        $trackingStartedTs = $trackingStartedTs ?: $now;

        $baselineTs = (int)($alert['baseline_captured_ts'] ?? 0);
        if ($baselineTs <= 0) {
            $baselineTs = self::alertTimeToTs($alert['baseline_captured_at'] ?? null);
        }

        $baselineClosedCandleTs = (int)($alert['baseline_closed_candle_ts'] ?? 0);

        /*
         * Do not let baseline_closed_candle_ts hide the live/forming candle after
         * tracking starts. We use the later of tracking/baseline timestamps as
         * the event start, then allow any candle whose close extends past it.
         */
        $eventStartTs = max($trackingStartedTs, $baselineTs ?: 0);

        $stage1Ts = (int)($alert['stage1_event_ts'] ?? 0);
        if ($stage1Ts <= 0) $stage1Ts = (int)($alert['stage1_sent_ts'] ?? 0);
        if ($stage1Ts <= 0) $stage1Ts = self::alertTimeToTs($alert['stage1_sent_at'] ?? null);

        $stage2Ts = (int)($alert['stage2_event_ts'] ?? 0);
        if ($stage2Ts <= 0) $stage2Ts = (int)($alert['stage2_sent_ts'] ?? 0);
        if ($stage2Ts <= 0) $stage2Ts = self::alertTimeToTs($alert['stage2_sent_at'] ?? null);

        $stage3Ts = (int)($alert['stage3_event_ts'] ?? 0);
        if ($stage3Ts <= 0) $stage3Ts = (int)($alert['stage3_sent_ts'] ?? 0);
        if ($stage3Ts <= 0) $stage3Ts = self::alertTimeToTs($alert['stage3_sent_at'] ?? null);

        $stage1Sent = ((int)($alert['stage1_sent_ts'] ?? 0) > 0) || !empty($alert['stage1_sent_at']);
        $stage2Sent = ((int)($alert['stage2_sent_ts'] ?? 0) > 0) || !empty($alert['stage2_sent_at']);
        $stage3Sent = ((int)($alert['stage3_sent_ts'] ?? 0) > 0) || !empty($alert['stage3_sent_at']);

        $zoneTouched = false;
        $liquiditySwept = false;
        $bos = false;
        $choch = false;

        $zoneEvidence = '';
        $sweepEvidence = '';
        $structureEvidence = '';

        $zoneEventTs = null;
        $sweepEventTs = null;
        $structureEventTs = null;

        $stage1Debug = [];

        $hasLivePriceForEarlyStages = ($price !== null && $priceSource !== 'latest_saved_candle_close');
        $hasAnyScanData = !empty($candles) || $hasLivePriceForEarlyStages;

        if (!$hasAnyScanData || $price === null || $zoneLow === null || $zoneHigh === null || !in_array($side, ['BUY', 'SELL'], true)) {
            return [
                'current_price' => $price,
                'candle_price' => $candlePrice,
                'live_crypto_price' => $liveCryptoPrice,
                'live_stage_price' => $liveStagePrice,
                'live_quote_provider' => $liveStageQuote['provider'] ?? null,
                'live_quote_source' => $liveStageQuote['source'] ?? null,
                'live_quote_time' => $liveQuoteTime,
                'live_quote_ts' => $liveQuoteTs,
                'price_source' => $priceSource,
                'market_open' => !empty($marketStatus['open']),
                'market_status_reason' => $marketStatus['reason'] ?? '',
                'market_session_code' => $marketStatus['session'] ?? null,
                'latest_market_open_ts' => $lastMarketOpenTs,
                'next_market_open_ts' => $marketStatus['next_open_ts'] ?? null,
                'setup_timeframe' => $setupTimeframe,
                'confirmation_timeframe' => $confirmationTimeframe,
                'scan_timeframe' => $scanTimeframe,
                'using_confirmation_timeframe' => $usingConfirmationTimeframe,
                'baseline_current_price' => self::num($alert['baseline_current_price'] ?? null),
                'stage1_direction_rule' => $side === 'BUY' ? 'BUY demand zone must be approached from above.' : 'SELL supply zone must be approached from below.',
                'confirmation_fresh' => $confirmationFresh,
                'setup_fresh' => $setupFresh,
                'confirmation_latest_ts' => $confirmationLatestTs,
                'setup_latest_ts' => $setupLatestTs,
                'liquidity_swept' => false,
                'bos_confirmed' => false,
                'choch_confirmed' => false,
                'zone_touched' => false,
                'zone_evidence' => 'Waiting for valid candle/zone data. Crypto Stage 1/2 can use live ticker; final structure still requires fresh candles.',
                'sweep_evidence' => '',
                'structure_evidence' => '',
                'stage_guard_note' => 'No valid live market/zone data available.',
                'tracking_started_ts' => $trackingStartedTs,
                'baseline_ts' => $baselineTs,
                'baseline_closed_candle_ts' => $baselineClosedCandleTs,
                'latest_closed_candle_ts' => $latestClosedTs,
                'closed_candle_time' => $closedCandleTime,
                'last_candle_is_closed' => false,
                'candles' => $candles,
                'scan_candle_count' => count($candles),
                'sweep_trigger_level' => null,
                'sweep_trigger_source' => 'none',
                'zone_event_ts' => null,
                'sweep_event_ts' => null,
                'structure_event_ts' => null,
                'stage1_debug' => $stage1Debug,
            ];
        }

        $baselineMissing = empty($alert['baseline_captured_at']) && empty($alert['baseline_captured_ts']);

        if ($baselineMissing) {
            $baselineZone = ($price >= $zoneLow && $price <= $zoneHigh);
            $baselineSweep = $side === 'SELL' ? ($price > $zoneHigh) : ($price < $zoneLow);

            return [
                'current_price' => $price,
                'candle_price' => $candlePrice,
                'live_crypto_price' => $liveCryptoPrice,
                'live_stage_price' => $liveStagePrice,
                'live_quote_provider' => $liveStageQuote['provider'] ?? null,
                'live_quote_source' => $liveStageQuote['source'] ?? null,
                'live_quote_time' => $liveQuoteTime,
                'live_quote_ts' => $liveQuoteTs,
                'price_source' => $priceSource,
                'market_open' => !empty($marketStatus['open']),
                'market_status_reason' => $marketStatus['reason'] ?? '',
                'market_session_code' => $marketStatus['session'] ?? null,
                'latest_market_open_ts' => $lastMarketOpenTs,
                'next_market_open_ts' => $marketStatus['next_open_ts'] ?? null,
                'setup_timeframe' => $setupTimeframe,
                'confirmation_timeframe' => $confirmationTimeframe,
                'scan_timeframe' => $scanTimeframe,
                'using_confirmation_timeframe' => $usingConfirmationTimeframe,
                'baseline_current_price' => self::num($alert['baseline_current_price'] ?? null),
                'stage1_direction_rule' => $side === 'BUY' ? 'BUY demand zone must be approached from above.' : 'SELL supply zone must be approached from below.',
                'confirmation_fresh' => $confirmationFresh,
                'setup_fresh' => $setupFresh,
                'confirmation_latest_ts' => $confirmationLatestTs,
                'setup_latest_ts' => $setupLatestTs,
                'liquidity_swept' => $baselineSweep,
                'bos_confirmed' => false,
                'choch_confirmed' => false,
                'zone_touched' => $baselineZone,
                'zone_evidence' => $baselineZone ? 'Baseline snapshot: price already inside the tracked zone.' : 'Baseline snapshot captured outside the tracked zone.',
                'sweep_evidence' => $baselineSweep ? 'Baseline snapshot: price already beyond the sweep boundary.' : '',
                'structure_evidence' => '',
                'stage_guard_note' => 'Baseline snapshot only. No stage alert can be sent on the first worker run.',
                'tracking_started_ts' => $trackingStartedTs,
                'baseline_ts' => $now,
                'baseline_closed_candle_ts' => $latestClosedTs,
                'latest_closed_candle_ts' => $latestClosedTs,
                'closed_candle_time' => $closedCandleTime,
                'last_candle_is_closed' => self::isLatestCandleClosed($scanTimeframe, $closedCandleTime),
                'candles' => $candles,
                'scan_candle_count' => count($candles),
                'sweep_trigger_level' => $side === 'BUY' ? $zoneLow : $zoneHigh,
                'sweep_trigger_source' => $side === 'BUY' ? 'zone_low_boundary' : 'zone_high_boundary',
                'zone_event_ts' => null,
                'sweep_event_ts' => null,
                'structure_event_ts' => null,
                'stage1_debug' => $stage1Debug,
            ];
        }

        /*
         * Stage 1: direction-aware zone event.
         * BUY: price must come from above into/through the demand zone.
         * SELL: price must come from below into/through the supply zone.
         * This version is deliberately event-driven and tolerant of cron delay:
         * if the move has already crossed the zone by the time cron runs, the
         * baseline/post-baseline evidence still promotes Stage 1.
         */
        if ($stage1Sent) {
            $zoneTouched = true;
            $zoneEvidence = 'Stage 1 was previously delivered.';
            $zoneEventTs = $stage1Ts ?: null;
        } else {
            $baselineZoneAlreadyTouched = !empty($alert['baseline_zone_touched']);
            $baselineStage = strtoupper((string)($alert['baseline_stage'] ?? ''));
            $baselinePrice = self::num($alert['baseline_current_price'] ?? null);
            $baselineApproachIsCorrect = !$baselineZoneAlreadyTouched && $baselinePrice !== null
                ? ($side === 'BUY' ? ($baselinePrice > $zoneHigh) : ($side === 'SELL' ? ($baselinePrice < $zoneLow) : false))
                : false;

            $wasAboveZoneAfterTracking = $baselineApproachIsCorrect && $side === 'BUY';
            $wasBelowZoneAfterTracking = $baselineApproachIsCorrect && $side === 'SELL';
            $previousClose = $baselinePrice;
            $wrongSideCrossDetected = false;

            /*
             * Stage 1 recovery:
             * Older/strict baseline logic recorded baseline_stage=ZONE_APPROACHED
             * and baseline_zone_touched=1, then blocked Stage 1 forever because
             * it treated the zone touch as pre-existing context. In production,
             * a tracked setup that is already at/through the zone by the first
             * worker check must still progress to Stage 1 so the engine can then
             * monitor the liquidity sweep.
             */
            $baselineZoneRecoveryAllowed = self::settingEnabled('stage1_recover_baseline_zone_touch', true);
            $baselineShowsZoneReached = $baselineZoneAlreadyTouched || in_array($baselineStage, ['ZONE_APPROACHED', 'LIQUIDITY_SWEPT', 'STRUCTURE_CONFIRMED', 'READY_TO_EXECUTE'], true);
            $latestPriceStillAtOrThroughZone = $price !== null && (
                ($side === 'BUY' && $price <= $zoneHigh)
                || ($side === 'SELL' && $price >= $zoneLow)
                || ($price >= $zoneLow && $price <= $zoneHigh)
            );

            if ($baselineZoneRecoveryAllowed && $baselineShowsZoneReached && $latestPriceStillAtOrThroughZone) {
                $zoneTouched = true;
                $zoneEventTs = max($baselineTs ?: $trackingStartedTs, $trackingStartedTs) + 1;
                $zoneEvidence = $side === 'BUY'
                    ? 'Stage 1 recovered: the tracked BUY demand zone had already been reached by the baseline check and price remains at/through the zone. Tracking now continues toward sell-side liquidity sweep.'
                    : 'Stage 1 recovered: the tracked SELL supply zone had already been reached by the baseline check and price remains at/through the zone. Tracking now continues toward buy-side liquidity sweep.';
            }

            if (!$zoneTouched) foreach ($candles as $candle) {
                $openTs = (int)($candle['__open_ts'] ?? 0);
                $closeTs = (int)($candle['__close_ts'] ?? 0);

                if ($closeTs <= $eventStartTs) {
                    $candidateClose = self::num($candle['close_price'] ?? null);
                    if ($candidateClose !== null) {
                        $previousClose = $candidateClose;
                    }
                    continue;
                }

                $open = self::candleValue($candle, 'open_price');
                $high = self::candleValue($candle, 'high_price');
                $low = self::candleValue($candle, 'low_price');
                $close = self::candleValue($candle, 'close_price');

                if ($high === null || $low === null) {
                    continue;
                }

                if (($open !== null && $open > $zoneHigh) || ($high > $zoneHigh) || ($close !== null && $close > $zoneHigh)) {
                    $wasAboveZoneAfterTracking = true;
                }

                if (($open !== null && $open < $zoneLow) || ($low < $zoneLow) || ($close !== null && $close < $zoneLow)) {
                    $wasBelowZoneAfterTracking = true;
                }

                $touchesZone = ($high >= $zoneLow && $low <= $zoneHigh);
                $buyCrossedDemandFromAbove = $side === 'BUY' && ($high >= $zoneHigh) && ($low <= $zoneHigh);
                $sellCrossedSupplyFromBelow = $side === 'SELL' && ($low <= $zoneLow) && ($high >= $zoneLow);

                $approachedFromCorrectSide = false;
                if ($side === 'BUY') {
                    $approachedFromCorrectSide = $wasAboveZoneAfterTracking
                        || ($previousClose !== null && $previousClose > $zoneHigh)
                        || ($open !== null && $open > $zoneHigh)
                        || ($high > $zoneHigh);

                    $wrongSideCrossDetected = !$approachedFromCorrectSide
                        && (($previousClose !== null && $previousClose < $zoneLow) || ($open !== null && $open < $zoneLow) || ($baselinePrice !== null && $baselinePrice < $zoneLow))
                        && $high >= $zoneLow;
                } else {
                    $approachedFromCorrectSide = $wasBelowZoneAfterTracking
                        || ($previousClose !== null && $previousClose < $zoneLow)
                        || ($open !== null && $open < $zoneLow)
                        || ($low < $zoneLow);

                    $wrongSideCrossDetected = !$approachedFromCorrectSide
                        && (($previousClose !== null && $previousClose > $zoneHigh) || ($open !== null && $open > $zoneHigh) || ($baselinePrice !== null && $baselinePrice > $zoneHigh))
                        && $low <= $zoneHigh;
                }

                if ($approachedFromCorrectSide && ($touchesZone || $buyCrossedDemandFromAbove || $sellCrossedSupplyFromBelow)) {
                    $zoneTouched = true;
                    $zoneEventTs = max($openTs ?: $eventStartTs, $eventStartTs + 1);
                    $zoneEvidence = $side === 'BUY'
                        ? 'Fresh BUY demand-zone approach detected from above. Price touched/crossed the tracked zone at ' . ($candle['open_time'] ?? '') . '. Stage 1 does not wait for a full candle close inside the zone.'
                        : 'Fresh SELL supply-zone approach detected from below. Price touched/crossed the tracked zone at ' . ($candle['open_time'] ?? '') . '. Stage 1 does not wait for a full candle close inside the zone.';
                    break;
                }

                if ($close !== null) {
                    $previousClose = $close;
                }
            }

            if (!$zoneTouched && !$baselineZoneAlreadyTouched && $price !== null) {
                if ($side === 'BUY' && (($baselinePrice !== null && $baselinePrice > $zoneHigh) || $wasAboveZoneAfterTracking) && $price <= $zoneHigh) {
                    $zoneTouched = true;
                    $zoneEventTs = $now;
                    $zoneEvidence = 'Fresh BUY pullback crossed into/through the tracked demand zone from above. Latest price is ' . $price . ', zone is ' . $zoneLow . ' - ' . $zoneHigh . '. This fallback catches fast moves between cron checks.';
                } elseif ($side === 'SELL' && (($baselinePrice !== null && $baselinePrice < $zoneLow) || $wasBelowZoneAfterTracking) && $price >= $zoneLow) {
                    $zoneTouched = true;
                    $zoneEventTs = $now;
                    $zoneEvidence = 'Fresh SELL pullback crossed into/through the tracked supply zone from below. Latest price is ' . $price . ', zone is ' . $zoneLow . ' - ' . $zoneHigh . '. This fallback catches fast moves between cron checks.';
                }
            }

            if (!$zoneTouched && $wrongSideCrossDetected) {
                $zoneEvidence = $side === 'BUY'
                    ? 'No valid Stage 1 yet: BUY setup must approach the demand zone from above. Move from below through the zone is not a pullback entry.'
                    : 'No valid Stage 1 yet: SELL setup must approach the supply zone from below. Move from above through the zone is not a pullback entry.';
            }

            $stage1Debug = [
                'baseline_price' => $baselinePrice,
                'latest_price' => $price,
                'zone_low' => $zoneLow,
                'zone_high' => $zoneHigh,
                'baseline_approach_correct' => $baselineApproachIsCorrect,
                'was_above_zone_after_tracking' => $wasAboveZoneAfterTracking,
                'was_below_zone_after_tracking' => $wasBelowZoneAfterTracking,
                'event_start_ts' => $eventStartTs,
                'price_source' => $priceSource,
            ];
        }

        $sweepTriggerLevel = $side === 'BUY' ? $zoneLow : $zoneHigh;
        $sweepTriggerSource = $side === 'BUY' ? 'zone_low_boundary' : 'zone_high_boundary';

        if ($stage2Sent) {
            $liquiditySwept = true;
            $sweepEvidence = 'Stage 2 was previously delivered.';
            $sweepEventTs = $stage2Ts ?: null;
        } elseif ($stage1Sent && $stage1Ts > 0) {
            foreach ($candles as $candle) {
                $openTs = (int)($candle['__open_ts'] ?? 0);
                $closeTs = (int)($candle['__close_ts'] ?? 0);

                if ($closeTs <= $stage1Ts) {
                    continue;
                }

                $open = self::candleValue($candle, 'open_price');
                $high = self::candleValue($candle, 'high_price');
                $low = self::candleValue($candle, 'low_price');
                $close = self::candleValue($candle, 'close_price');

                if ($high === null || $low === null) {
                    continue;
                }

                if ($side === 'SELL') {
                    $crossedFromZoneSide = ($low <= $sweepTriggerLevel) || ($open !== null && $open <= $sweepTriggerLevel) || ($close !== null && $close <= $sweepTriggerLevel);
                    if ($crossedFromZoneSide && $high > $sweepTriggerLevel) {
                        $liquiditySwept = true;
                        $sweepEventTs = max($openTs ?: $stage1Ts, $stage1Ts + 1);
                        $sweepEvidence = 'Fresh buy-side liquidity sweep above the tracked zone high after Stage 1 at ' . ($candle['open_time'] ?? '') . '.';
                        break;
                    }
                } else {
                    $crossedFromZoneSide = ($high >= $sweepTriggerLevel) || ($open !== null && $open >= $sweepTriggerLevel) || ($close !== null && $close >= $sweepTriggerLevel);
                    if ($crossedFromZoneSide && $low < $sweepTriggerLevel) {
                        $liquiditySwept = true;
                        $sweepEventTs = max($openTs ?: $stage1Ts, $stage1Ts + 1);
                        $sweepEvidence = 'Fresh sell-side liquidity sweep below the tracked zone low after Stage 1 at ' . ($candle['open_time'] ?? '') . '.';
                        break;
                    }
                }
            }
        }

        /*
         * Stage 2 live-price fallback for fast crypto moves:
         * If Stage 1 has already been delivered and the live ticker is beyond
         * the opposite side of the zone, treat it as the liquidity sweep event.
         * Stage 3/final execution still require closed-candle structure.
         */
        if (!$liquiditySwept && $stage1Sent && $price !== null && $priceSource !== 'latest_saved_candle_close') {
            if ($side === 'BUY' && $price < $sweepTriggerLevel) {
                $liquiditySwept = true;
                $sweepEventTs = $now;
                $sweepEvidence = 'Live sell-side liquidity sweep below the tracked zone low after Stage 1. Latest live price is ' . $price . ', sweep boundary is ' . $sweepTriggerLevel . '.';
            } elseif ($side === 'SELL' && $price > $sweepTriggerLevel) {
                $liquiditySwept = true;
                $sweepEventTs = $now;
                $sweepEvidence = 'Live buy-side liquidity sweep above the tracked zone high after Stage 1. Latest live price is ' . $price . ', sweep boundary is ' . $sweepTriggerLevel . '.';
            }
        }

        if ($stage3Sent) {
            $bos = true;
            $choch = true;
            $structureEvidence = 'Stage 3 was previously delivered.';
            $structureEventTs = $stage3Ts ?: null;
        } elseif ($stage2Sent && $stage2Ts > 0) {
            $count = count($closedCandles);
            for ($i = 3; $i < $count; $i++) {
                $current = $closedCandles[$i];
                $currentCloseTs = (int)($current['__close_ts'] ?? 0);

                if ($currentCloseTs <= $stage2Ts) {
                    continue;
                }

                $window = array_slice($closedCandles, max(0, $i - 5), min(5, $i));
                if (count($window) < 3) {
                    continue;
                }

                $prevHigh = max(array_map(static function ($c) { return (float)($c['high_price'] ?? 0); }, $window));
                $prevLow = min(array_map(static function ($c) { return (float)($c['low_price'] ?? PHP_FLOAT_MAX); }, $window));

                $open = self::candleValue($current, 'open_price');
                $close = self::candleValue($current, 'close_price');
                $high = self::candleValue($current, 'high_price');
                $low = self::candleValue($current, 'low_price');

                if ($open === null || $close === null || $high === null || $low === null || $high <= $low) {
                    continue;
                }

                $range = max(0.00000001, $high - $low);
                $bodyRatio = abs($close - $open) / $range;
                $bodyRatioRequired = (float)(self::getSetting('displacement_min_body_ratio') ?: 0.50);
                $bodyRatioRequired = max(0.30, min(0.90, $bodyRatioRequired));

                $bullishDisplacement = $close > $open && $bodyRatio >= $bodyRatioRequired && $close >= ($low + ($range * 0.60));
                $bearishDisplacement = $close < $open && $bodyRatio >= $bodyRatioRequired && $close <= ($high - ($range * 0.60));

                if ($side === 'BUY' && $close > $prevHigh && $bullishDisplacement) {
                    $bos = true;
                    $choch = true;
                    $structureEventTs = $currentCloseTs;
                    $structureEvidence = 'Fresh bullish BOS/CHoCH with displacement confirmed by closed candle at ' . ($current['open_time'] ?? '') . '. Body ratio: ' . number_format($bodyRatio * 100, 1) . '%.';
                    break;
                }

                if ($side === 'SELL' && $close < $prevLow && $bearishDisplacement) {
                    $bos = true;
                    $choch = true;
                    $structureEventTs = $currentCloseTs;
                    $structureEvidence = 'Fresh bearish BOS/CHoCH with displacement confirmed by closed candle at ' . ($current['open_time'] ?? '') . '. Body ratio: ' . number_format($bodyRatio * 100, 1) . '%.';
                    break;
                }
            }
        }

        if (self::developerSimulationEnabled($alert)) {
            $forceZone = self::settingEnabled('simulate_zone_touch');
            $forceSweep = self::settingEnabled('simulate_liquidity_sweep');
            $forceBos = self::settingEnabled('simulate_structure_break');
            $forceReady = self::settingEnabled('simulate_ready_setup');

            if ($forceReady) {
                $forceZone = true;
                $forceSweep = true;
                $forceBos = true;
            }

            $zoneTouched = $forceZone ?: $zoneTouched;
            $liquiditySwept = $forceSweep ?: $liquiditySwept;
            $bos = $forceBos ?: $bos;
            $choch = $forceBos ?: $choch;
            $zoneEventTs = $forceZone ? $now : $zoneEventTs;
            $sweepEventTs = $forceSweep ? $now : $sweepEventTs;
            $structureEventTs = $forceBos ? $now : $structureEventTs;
            $zoneEvidence = $forceZone ? 'Developer test mode: zone simulated.' : $zoneEvidence;
            $sweepEvidence = $forceSweep ? 'Developer test mode: sweep simulated.' : $sweepEvidence;
            $structureEvidence = $forceBos ? 'Developer test mode: structure simulated.' : $structureEvidence;
        }

        return [
            'current_price' => $price,
            'candle_price' => $candlePrice,
            'live_crypto_price' => $liveCryptoPrice,
            'live_stage_price' => $liveStagePrice,
            'live_quote_provider' => $liveStageQuote['provider'] ?? null,
            'live_quote_source' => $liveStageQuote['source'] ?? null,
            'live_quote_time' => $liveQuoteTime,
            'live_quote_ts' => $liveQuoteTs,
            'price_source' => $priceSource,
            'market_open' => !empty($marketStatus['open']),
            'market_status_reason' => $marketStatus['reason'] ?? '',
            'market_session_code' => $marketStatus['session'] ?? null,
            'latest_market_open_ts' => $lastMarketOpenTs,
            'next_market_open_ts' => $marketStatus['next_open_ts'] ?? null,
            'setup_timeframe' => $setupTimeframe,
            'confirmation_timeframe' => $confirmationTimeframe,
            'scan_timeframe' => $scanTimeframe,
            'using_confirmation_timeframe' => $usingConfirmationTimeframe,
            'baseline_current_price' => self::num($alert['baseline_current_price'] ?? null),
            'stage1_direction_rule' => $side === 'BUY' ? 'BUY demand zone must be approached from above.' : 'SELL supply zone must be approached from below.',
            'confirmation_fresh' => $confirmationFresh,
            'setup_fresh' => $setupFresh,
            'confirmation_latest_ts' => $confirmationLatestTs,
            'setup_latest_ts' => $setupLatestTs,
            'liquidity_swept' => $liquiditySwept,
            'bos_confirmed' => $bos,
            'choch_confirmed' => $choch,
            'zone_touched' => $zoneTouched,
            'zone_evidence' => $zoneEvidence,
            'sweep_evidence' => $sweepEvidence,
            'structure_evidence' => $structureEvidence,
            'stage_guard_note' => 'Professional event-state mode: live quote can trigger Stage 1/2 for fast moves; fresh closed candles confirm Stage 3 structure and final execution; setup timeframe defines the zone and mapped LTF monitors staged confirmation.',
            'tracking_started_ts' => $trackingStartedTs,
            'baseline_ts' => $baselineTs,
            'baseline_closed_candle_ts' => $baselineClosedCandleTs,
            'stage1_reference_ts' => $stage1Ts,
            'stage2_reference_ts' => $stage2Ts,
            'stage3_reference_ts' => $stage3Ts,
            'latest_closed_candle_ts' => $latestClosedTs,
            'closed_candle_time' => $closedCandleTime,
            'last_candle_is_closed' => self::isLatestCandleClosed($scanTimeframe, $closedCandleTime),
            'candles' => $candles,
            'scan_candle_count' => count($candles),
            'sweep_trigger_level' => $sweepTriggerLevel,
            'sweep_trigger_source' => $sweepTriggerSource,
            'zone_event_ts' => $zoneEventTs,
            'sweep_event_ts' => $sweepEventTs,
            'structure_event_ts' => $structureEventTs,
            'stage1_debug' => $stage1Debug,
        ];
    }




        private static function evaluateSignalQuality(array $alert, array $market): array
    {
        /*
        |--------------------------------------------------------------------------
        | Institutional quality scoring engine
        |--------------------------------------------------------------------------
        | This version keeps MTF confirmation flexible instead of hard-blocking by
        | default. Admin can control MTF ON/OFF, score weighting, hard-block mode,
        | and style-based strictness from system_settings / alert_settings.php.
        |--------------------------------------------------------------------------
        */

        if (self::developerSimulationEnabled($alert)) {
            return [
                'passed' => true,
                'score' => 100,
                'reason' => 'Developer test mode active.',
                'session' => 'TEST_MODE',
                'atr' => null,
                'volatility_status' => 'BYPASSED',
                'mtf_passed' => true,
                'mtf_status' => 'BYPASSED',
                'mtf_reason' => 'MTF confirmation bypassed in developer test mode.',
                'mtf_score' => 100,
                'mtf_strictness' => 'TEST_MODE',
                'candle_close_passed' => true,
                'candle_close_status' => 'BYPASSED',
                'candle_close_reason' => 'Candle-close confirmation bypassed in developer test mode.'
            ];
        }

        $score = 100.0;
        $reasons = [];

        $marketStatus = self::marketOpenStatus((string)($alert['symbol'] ?? ''), time());
        $marketOpen = !empty($marketStatus['open']);
        $session = $marketOpen ? self::currentTradingSession() : 'MARKET_CLOSED';
        $sessionPassed = $marketOpen && self::sessionFilterPassed($session);

        if (!$marketOpen) {
            $score -= 60;
            $reasons[] = $marketStatus['reason'] ?? 'Market is closed. Waiting for market reopen before final readiness.';
        } elseif (!$sessionPassed) {
            $score -= 35;
            $reasons[] = 'Outside allowed trading sessions.';
        } else {
            $reasons[] = 'Session filter passed: ' . $session . '.';
        }

        $volatility = self::volatilityFilterPassed($market);

        if (empty($volatility['passed'])) {
            $score -= 35;
            $reasons[] = $volatility['reason'];
        } else {
            $reasons[] = $volatility['reason'];
        }

        $mtf = self::multiTimeframeFilterPassed($alert);

        /*
        |--------------------------------------------------------------------------
        | MTF is scored, not blindly hard-blocked
        |--------------------------------------------------------------------------
        | Unless mtf_hard_block is enabled, MTF disagreement reduces confidence.
        | This prevents useful scalper/intraday setups from being overblocked.
        |--------------------------------------------------------------------------
        */

        $mtfWeight = (float)(self::getSetting('mtf_score_weight') ?: 25);
        $mtfWeight = max(0, min(50, $mtfWeight));
        $mtfScore = (float)($mtf['score'] ?? 100);
        $mtfPenalty = $mtfWeight * ((100 - $mtfScore) / 100);
        $score -= $mtfPenalty;
        $reasons[] = $mtf['reason'] ?? 'MTF status unavailable.';

        $mtfHardBlock = self::settingEnabled('mtf_hard_block', false) || (($mtf['strictness'] ?? '') === 'HARD_BLOCK');

        $candleClose = self::directionalCandleCloseFilterPassed($alert);

        if (empty($candleClose['passed'])) {
            $score -= 20;
            $reasons[] = $candleClose['reason'];
        } else {
            $reasons[] = $candleClose['reason'];
        }

        $minimumScore = (float)(self::getSetting('minimum_quality_score') ?: 60);
        $score = max(0, min(100, $score));

        $passed = $sessionPassed
            && !empty($volatility['passed'])
            && !empty($candleClose['passed'])
            && (!$mtfHardBlock || !empty($mtf['passed']))
            && $score >= $minimumScore;

        self::saveMtfStatus((int)($alert['id'] ?? 0), $mtf);

        return [
            'passed' => $passed,
            'score' => $score,
            'reason' => implode(' ', $reasons),
            'session' => $session,
            'session_passed' => $sessionPassed,
            'volatility_passed' => !empty($volatility['passed']),
            'atr' => $volatility['atr'] ?? null,
            'volatility_status' => $volatility['status'] ?? 'UNKNOWN',
            'mtf_passed' => !empty($mtf['passed']),
            'mtf_status' => $mtf['status'] ?? 'UNKNOWN',
            'mtf_reason' => $mtf['reason'] ?? '',
            'mtf_score' => $mtfScore,
            'mtf_weight' => $mtfWeight,
            'mtf_penalty' => round($mtfPenalty, 2),
            'mtf_strictness' => $mtf['strictness'] ?? 'UNKNOWN',
            'candle_close_passed' => !empty($candleClose['passed']),
            'candle_close_status' => $candleClose['status'] ?? 'N/A',
            'candle_close_reason' => $candleClose['reason'] ?? ''
        ];
    }


    private static function multiTimeframeFilterPassed(array $alert): array
    {
        /*
        |--------------------------------------------------------------------------
        | Flexible multi-timeframe confirmation
        |--------------------------------------------------------------------------
        | Admin controls:
        | - enable_mtf_confirmation: 1/0
        | - mtf_score_weight: e.g. 25
        | - mtf_strictness_mode: style_based|relaxed|medium|strict|hard_block
        | - mtf_required_agreement_count: optional override
        | - mtf_confirmation_timeframes: optional comma list
        |--------------------------------------------------------------------------
        */

        if (self::developerSimulationEnabled($alert)) {
            return [
                'passed' => true,
                'status' => 'BYPASSED',
                'reason' => 'MTF confirmation bypassed in developer test mode.',
                'score' => 100,
                'strictness' => 'TEST_MODE'
            ];
        }

        if (!self::settingEnabled('enable_mtf_confirmation', true)) {
            return [
                'passed' => true,
                'status' => 'DISABLED',
                'reason' => 'Multi-timeframe confirmation disabled by admin.',
                'score' => 100,
                'strictness' => 'DISABLED'
            ];
        }

        $symbol = (string)($alert['symbol'] ?? '');
        $side = strtoupper((string)($alert['side'] ?? ''));

        if (!$symbol || !in_array($side, ['BUY', 'SELL'], true)) {
            return [
                'passed' => false,
                'status' => 'INVALID_ALERT',
                'reason' => 'MTF confirmation failed: invalid symbol or side.',
                'score' => 0,
                'strictness' => 'UNKNOWN'
            ];
        }

        $strictness = self::mtfStrictnessForAlert($alert);
        $higherTimeframes = self::getMtfConfirmationTimeframes((string)($alert['timeframe'] ?? ''), $strictness);

        if (!$higherTimeframes) {
            return [
                'passed' => true,
                'status' => 'NO_HTF_REQUIRED',
                'reason' => 'No higher timeframe confirmation required for this timeframe.',
                'score' => 100,
                'strictness' => $strictness
            ];
        }

        $requiredAgree = self::mtfRequiredAgreementCount($strictness, count($higherTimeframes));
        $agree = 0;
        $checked = 0;
        $neutral = 0;
        $opposed = 0;
        $details = [];

        foreach ($higherTimeframes as $tf) {
            $candles = self::getRecentCandles($symbol, $tf, 80);

            if (count($candles) < 20) {
                $details[] = "{$tf}: insufficient candles";
                continue;
            }

            $bias = self::detectSimpleHtfBias($candles);
            $checked++;

            $aligned = ($side === 'BUY' && $bias === 'BULLISH') || ($side === 'SELL' && $bias === 'BEARISH');
            $opposite = ($side === 'BUY' && $bias === 'BEARISH') || ($side === 'SELL' && $bias === 'BULLISH');

            if ($aligned) {
                $agree++;
                $details[] = "{$tf}: {$bias} aligned";
            } elseif ($opposite) {
                $opposed++;
                $details[] = "{$tf}: {$bias} opposed";
            } else {
                $neutral++;
                $details[] = "{$tf}: {$bias}";
            }
        }

        if ($checked === 0) {
            return [
                'passed' => $strictness === 'RELAXED',
                'status' => 'NO_HTF_DATA',
                'reason' => 'MTF confirmation: no usable higher-timeframe candle data. ' . ($strictness === 'RELAXED' ? 'Relaxed mode allows score-based continuation.' : 'Strictness requires usable HTF data.'),
                'score' => $strictness === 'RELAXED' ? 65 : 25,
                'strictness' => $strictness
            ];
        }

        $alignmentRatio = $checked > 0 ? ($agree / $checked) : 0;
        $oppositionRatio = $checked > 0 ? ($opposed / $checked) : 0;

        $score = round(($alignmentRatio * 100) - ($oppositionRatio * 35));
        $score = max(0, min(100, $score));

        if ($strictness === 'RELAXED' && $score < 50 && $opposed === 0) {
            $score = 55;
        }

        if ($strictness === 'MEDIUM' && $score < 45 && $agree > 0) {
            $score = 50;
        }

        $passed = $agree >= $requiredAgree;

        if ($strictness === 'RELAXED') {
            $passed = $agree >= 1 || $opposed === 0;
        }

        if ($strictness === 'HARD_BLOCK') {
            $passed = $agree >= $requiredAgree && $opposed === 0;
        }

        $status = $passed ? 'PASSED' : 'SCORE_ONLY';

        if ($strictness === 'HARD_BLOCK' && !$passed) {
            $status = 'FAILED_HARD_BLOCK';
        }

        return [
            'passed' => $passed,
            'status' => $status,
            'reason' => 'MTF ' . strtolower($status) . " ({$strictness}). Agreement {$agree}/{$checked}, opposed {$opposed}, neutral {$neutral}, score {$score}. " . implode('; ', $details),
            'score' => $score,
            'strictness' => $strictness,
            'agree' => $agree,
            'checked' => $checked,
            'opposed' => $opposed,
            'neutral' => $neutral
        ];
    }

    private static function mtfStrictnessForAlert(array $alert): string
    {
        $style = strtolower(trim((string)($alert['style'] ?? '')));

        if (self::containsText($style, 'scalp')) {
            $value = self::getSetting('mtf_strictness_scalper') ?: 'relaxed';
        } elseif (self::containsText($style, 'swing') || self::containsText($style, 'position')) {
            $value = self::getSetting('mtf_strictness_swing') ?: 'strict';
        } elseif (self::containsText($style, 'intra') || self::containsText($style, 'day')) {
            $value = self::getSetting('mtf_strictness_intraday') ?: 'medium';
        } else {
            $value = self::getSetting('mtf_strictness_mode') ?: self::getSetting('mtf_confirmation_mode') ?: 'medium';
        }

        $mode = strtoupper(trim((string)$value));
        $mode = str_replace('-', '_', $mode);

        if ($mode === 'SCORE') {
            $mode = 'MEDIUM';
        }

        if (in_array($mode, ['RELAXED', 'MEDIUM', 'STRICT', 'HARD_BLOCK'], true)) {
            return $mode;
        }

        return 'MEDIUM';
    }

    private static function mtfRequiredAgreementCount(string $strictness, int $available): int
    {
        $override = self::getSetting('mtf_required_agreement_count');

        if ($override !== null && $override !== false && $override !== '') {
            return max(1, min($available, (int)$override));
        }

        if ($available <= 1) {
            return 1;
        }

        if ($strictness === 'RELAXED') {
            return 1;
        }

        if ($strictness === 'STRICT' || $strictness === 'HARD_BLOCK') {
            return $available;
        }

        return max(1, (int)ceil($available / 2));
    }

    private static function getMtfConfirmationTimeframes(string $entryTimeframe, string $strictness = 'MEDIUM'): array
    {
        $configured = trim((string)(self::getSetting('mtf_confirmation_timeframes') ?: ''));

        if ($configured !== '') {
            return array_values(array_filter(array_map('trim', explode(',', $configured))));
        }

        $tf = strtolower(trim($entryTimeframe));

        $relaxed = [
            '1m' => ['5m'],
            '3m' => ['15m'],
            '5m' => ['15m'],
            '15m' => ['1h'],
            '30m' => ['1h'],
            '1h' => ['4h'],
            '2h' => ['4h'],
            '4h' => ['1d'],
            '1d' => ['1w'],
        ];

        $medium = [
            '1m' => ['5m', '15m'],
            '3m' => ['15m', '30m'],
            '5m' => ['15m', '1h'],
            '15m' => ['1h', '4h'],
            '30m' => ['1h', '4h'],
            '1h' => ['4h', '1d'],
            '2h' => ['4h', '1d'],
            '4h' => ['1d'],
            '1d' => ['1w'],
        ];

        $strict = [
            '1m' => ['5m', '15m', '1h'],
            '3m' => ['15m', '30m', '1h'],
            '5m' => ['15m', '1h', '4h'],
            '15m' => ['1h', '4h', '1d'],
            '30m' => ['1h', '4h', '1d'],
            '1h' => ['4h', '1d'],
            '2h' => ['4h', '1d'],
            '4h' => ['1d', '1w'],
            '1d' => ['1w'],
        ];

        if ($strictness === 'RELAXED') {
            return $relaxed[$tf] ?? [];
        }

        if ($strictness === 'STRICT' || $strictness === 'HARD_BLOCK') {
            return $strict[$tf] ?? [];
        }

        return $medium[$tf] ?? [];
    }

    private static function detectSimpleHtfBias(array $candles): string
    {
        /*
        |--------------------------------------------------------------------------
        | Simple HTF bias model
        |--------------------------------------------------------------------------
        | Uses EMA-style moving-average slope + recent high/low structure. It is
        | intentionally lightweight so it can run safely inside cron.
        |--------------------------------------------------------------------------
        */

        $closes = array_map(fn($c) => (float)($c['close_price'] ?? 0), $candles);
        $closes = array_values(array_filter($closes, fn($v) => $v > 0));

        if (count($closes) < 20) {
            return 'NEUTRAL';
        }

        $fast = self::simpleAverage(array_slice($closes, -10));
        $slow = self::simpleAverage(array_slice($closes, -20));
        $last = end($closes);

        $recent = array_slice($candles, -10);
        $previous = array_slice($candles, -20, 10);

        $recentHigh = max(array_map(fn($c) => (float)($c['high_price'] ?? 0), $recent));
        $recentLow = min(array_map(fn($c) => (float)($c['low_price'] ?? PHP_FLOAT_MAX), $recent));

        $previousHigh = max(array_map(fn($c) => (float)($c['high_price'] ?? 0), $previous));
        $previousLow = min(array_map(fn($c) => (float)($c['low_price'] ?? PHP_FLOAT_MAX), $previous));

        $higherHigh = $recentHigh > $previousHigh;
        $higherLow = $recentLow > $previousLow;
        $lowerHigh = $recentHigh < $previousHigh;
        $lowerLow = $recentLow < $previousLow;

        if ($last > $fast && $fast > $slow && ($higherHigh || $higherLow)) {
            return 'BULLISH';
        }

        if ($last < $fast && $fast < $slow && ($lowerHigh || $lowerLow)) {
            return 'BEARISH';
        }

        return 'NEUTRAL';
    }

    private static function simpleAverage(array $values): float
    {
        $values = array_values(array_filter($values, fn($v) => is_numeric($v)));

        if (!$values) {
            return 0.0;
        }

        return array_sum($values) / count($values);
    }

    private static function saveMtfStatus(int $alertId, array $mtf): void
    {
        if ($alertId <= 0) {
            return;
        }

        try {
            db()->prepare("
                UPDATE institutional_alerts
                SET last_mtf_status = ?,
                    last_mtf_reason = ?,
                    last_mtf_score = ?,
                    last_mtf_strictness = ?,
                    last_mtf_checked_at = NOW(),
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([
                $mtf['status'] ?? null,
                $mtf['reason'] ?? null,
                $mtf['score'] ?? null,
                $mtf['strictness'] ?? null,
                $alertId
            ]);
        } catch (Throwable $e) {
        }
    }


    private static function directionalCandleCloseFilterPassed(array $alert): array
    {
        /*
        |--------------------------------------------------------------------------
        | Phase 5: Candle-close confirmation
        |--------------------------------------------------------------------------
        | Prevents alerts from triggering intrabar before the candle closes.
        |--------------------------------------------------------------------------
        */

        if (self::developerSimulationEnabled($alert)) {
            return [
                'passed' => true,
                'status' => 'BYPASSED',
                'reason' => 'Candle-close confirmation bypassed in test mode.'
            ];
        }

        if (!self::settingEnabled('require_candle_close_confirmation', true)) {
            return [
                'passed' => true,
                'status' => 'DISABLED',
                'reason' => 'Candle-close confirmation disabled.'
            ];
        }

        $symbol = (string)($alert['symbol'] ?? '');
        $timeframe = (string)($alert['timeframe'] ?? '');
        $side = strtoupper((string)($alert['side'] ?? ''));

        $timeframe = self::normalizeAlertTimeframe($timeframe);
        $rawCandles = self::sortCandlesByOpenTime(self::getRecentCandles($symbol, $timeframe, 12));
        $candles = self::filterTradableCandles($symbol, self::enrichCandlesWithTimes($rawCandles, $timeframe, time()));
        $closedCandles = array_values(array_filter($candles, static function ($c) {
            return !empty($c['__is_closed']);
        }));

        if (count($closedCandles) < 1) {
            return [
                'passed' => false,
                'status' => 'NO_DATA',
                'reason' => 'Candle-close confirmation is waiting for a valid closed candle during open market hours.'
            ];
        }

        $closed = end($closedCandles);
        if (!is_array($closed) || !self::candleOpenAllowedForSymbol($symbol, (int)($closed['__open_ts'] ?? 0), $timeframe)) {
            return [
                'passed' => false,
                'status' => 'MARKET_CLOSED_CANDLE',
                'reason' => 'Latest candle is not from a valid open-market session. Waiting for a fresh tradable candle.'
            ];
        }
        $close = (float)($closed['close_price'] ?? 0);
        $high = (float)($closed['high_price'] ?? 0);
        $low = (float)($closed['low_price'] ?? 0);

        $mid = ($high + $low) / 2;

        $passed = false;

        if ($side === 'BUY') {
            $passed = $close > $mid;
        }

        if ($side === 'SELL') {
            $passed = $close < $mid;
        }

        self::saveCandleConfirmationStatus(
            (int)($alert['id'] ?? 0),
            $passed ? 'PASSED' : 'FAILED'
        );

        return [
            'passed' => $passed,
            'status' => $passed ? 'PASSED' : 'FAILED',
            'reason' => $passed
                ? 'Closed candle confirmed momentum continuation.'
                : 'Latest closed candle did not confirm directional continuation.'
        ];
    }

    private static function saveCandleConfirmationStatus(int $alertId, string $status): void
    {
        if ($alertId <= 0) {
            return;
        }

        try {
            db()->prepare("
                UPDATE institutional_alerts
                SET last_candle_confirmation = ?,
                    last_candle_checked_at = NOW(),
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([$status, $alertId]);
        } catch (Throwable $e) {
        }
    }

    private static function currentTradingSession(): string
    {
        $timezone = self::getSetting('market_session_timezone') ?: 'Africa/Lagos';

        try {
            $dt = new DateTime('now', new DateTimeZone($timezone));
        } catch (Throwable $e) {
            $dt = new DateTime('now', new DateTimeZone('Africa/Lagos'));
        }

        $hour = (int)$dt->format('G');

        /*
        |--------------------------------------------------------------------------
        | Approximate sessions in Africa/Lagos / London-aligned time
        |--------------------------------------------------------------------------
        | Asian:   00:00 - 07:59
        | London: 08:00 - 15:59
        | NewYork:13:00 - 21:59
        | Overlap:13:00 - 15:59
        |--------------------------------------------------------------------------
        */

        if ($hour >= 13 && $hour <= 15) {
            return 'LONDON_NY_OVERLAP';
        }

        if ($hour >= 8 && $hour <= 15) {
            return 'LONDON';
        }

        if ($hour >= 13 && $hour <= 21) {
            return 'NEW_YORK';
        }

        if ($hour >= 0 && $hour <= 7) {
            return 'ASIAN';
        }

        return 'OFF_SESSION';
    }

    private static function sessionFilterPassed(string $session): bool
    {
        if (!self::settingEnabled('enable_session_filter', false)) {
            return true;
        }

        $allowed = self::getSetting('allowed_trading_sessions');

        if (!$allowed) {
            $allowed = 'LONDON,LONDON_NY_OVERLAP,NEW_YORK';
        }

        $allowedSessions = array_map('trim', explode(',', strtoupper((string)$allowed)));

        return in_array(strtoupper($session), $allowedSessions, true);
    }

    private static function volatilityFilterPassed(array $market): array
    {
        if (!self::settingEnabled('enable_volatility_filter', false)) {
            return [
                'passed' => true,
                'reason' => 'Volatility filter disabled.',
                'atr' => null,
                'status' => 'DISABLED'
            ];
        }

        $candles = $market['candles'] ?? [];

        $atr = self::calculateAtr($candles, (int)(self::getSetting('atr_period') ?: 14));

        if ($atr === null) {
            return [
                'passed' => false,
                'reason' => 'Not enough candle data to calculate ATR.',
                'atr' => null,
                'status' => 'NO_ATR'
            ];
        }

        $currentPrice = self::num($market['current_price'] ?? null);
        $atrPercent = $currentPrice && $currentPrice > 0 ? ($atr / $currentPrice) * 100 : null;

        $minAtrPercent = self::getSetting('min_atr_percent');
        $maxAtrPercent = self::getSetting('max_atr_percent');

        $minAtrPercent = $minAtrPercent !== false && $minAtrPercent !== null && $minAtrPercent !== ''
            ? (float)$minAtrPercent
            : 0.02;

        $maxAtrPercent = $maxAtrPercent !== false && $maxAtrPercent !== null && $maxAtrPercent !== ''
            ? (float)$maxAtrPercent
            : 3.00;

        if ($atrPercent === null) {
            return [
                'passed' => false,
                'reason' => 'Current price unavailable for ATR percentage check.',
                'atr' => $atr,
                'status' => 'NO_PRICE'
            ];
        }

        if ($atrPercent < $minAtrPercent) {
            return [
                'passed' => false,
                'reason' => 'Volatility too low. ATR%=' . round($atrPercent, 4) . ' is below minimum ' . $minAtrPercent . '%.',
                'atr' => $atr,
                'status' => 'LOW_VOLATILITY'
            ];
        }

        if ($atrPercent > $maxAtrPercent) {
            return [
                'passed' => false,
                'reason' => 'Volatility too high. ATR%=' . round($atrPercent, 4) . ' is above maximum ' . $maxAtrPercent . '%.',
                'atr' => $atr,
                'status' => 'HIGH_VOLATILITY'
            ];
        }

        return [
            'passed' => true,
            'reason' => 'Volatility filter passed. ATR%=' . round($atrPercent, 4) . '.',
            'atr' => $atr,
            'status' => 'OK'
        ];
    }

    private static function calculateAtr(array $candles, int $period = 14): ?float
    {
        if (count($candles) < ($period + 1)) {
            return null;
        }

        $recent = array_slice($candles, -($period + 1));
        $trs = [];

        for ($i = 1; $i < count($recent); $i++) {
            $high = (float)($recent[$i]['high_price'] ?? 0);
            $low = (float)($recent[$i]['low_price'] ?? 0);
            $prevClose = (float)($recent[$i - 1]['close_price'] ?? 0);

            if ($high <= 0 || $low <= 0 || $prevClose <= 0) {
                continue;
            }

            $tr = max(
                $high - $low,
                abs($high - $prevClose),
                abs($low - $prevClose)
            );

            $trs[] = $tr;
        }

        if (count($trs) < $period) {
            return null;
        }

        return array_sum(array_slice($trs, -$period)) / $period;
    }

    private static function candleCloseConfirmationPassed(array $alert, array $market, bool $structureConfirmed): bool
    {
        /*
        |--------------------------------------------------------------------------
        | Candle-close confirmation
        |--------------------------------------------------------------------------
        | BOS/CHoCH is accepted only after the latest candle has closed. This avoids
        | alerts being triggered by temporary wick/price movement on a live candle.
        |--------------------------------------------------------------------------
        */

        if (!$structureConfirmed) {
            return false;
        }

        if (self::developerSimulationEnabled($alert)) {
            return true;
        }

        if (!self::settingEnabled('require_candle_close_confirmation', true)) {
            return true;
        }

        return !empty($market['last_candle_is_closed']);
    }

    private static function isLatestCandleClosed(string $timeframe, $openTime): bool
    {
        if (!$openTime) {
            return false;
        }

        $seconds = self::timeframeToSeconds($timeframe);

        if ($seconds <= 0) {
            return true;
        }

        $openedAt = self::alertTimeToTs($openTime);

        if (!$openedAt) {
            return false;
        }

        return time() >= ($openedAt + $seconds);
    }

    private static function timeframeToSeconds(string $timeframe): int
    {
        $tf = strtolower(trim($timeframe));

        $map = [
            '1m' => 60,
            '3m' => 180,
            '5m' => 300,
            '15m' => 900,
            '30m' => 1800,
            '1h' => 3600,
            '2h' => 7200,
            '4h' => 14400,
            '6h' => 21600,
            '8h' => 28800,
            '12h' => 43200,
            '1d' => 86400,
            '1w' => 604800,
        ];

        if (isset($map[$tf])) {
            return $map[$tf];
        }

        if (preg_match('/^(\d+)\s*m$/', $tf, $m)) {
            return (int)$m[1] * 60;
        }

        if (preg_match('/^(\d+)\s*h$/', $tf, $m)) {
            return (int)$m[1] * 3600;
        }

        if (preg_match('/^(\d+)\s*d$/', $tf, $m)) {
            return (int)$m[1] * 86400;
        }

        return 0;
    }

    private static function stageSignature(array $alert, string $stage, array $market): string
    {
        return md5(implode('|', [
            'stage',
            $alert['id'] ?? '',
            $alert['setup_key'] ?? '',
            $stage,
            $market['closed_candle_time'] ?? '',
        ]));
    }

    private static function finalSignature(array $alert, array $dynamicAlert, array $market): string
    {
        return md5(implode('|', [
            'final',
            $alert['id'] ?? '',
            $alert['setup_key'] ?? '',
            $dynamicAlert['entry_price'] ?? '',
            $dynamicAlert['entry_low'] ?? '',
            $dynamicAlert['entry_high'] ?? '',
            $dynamicAlert['stop_loss'] ?? '',
            $dynamicAlert['tp1'] ?? '',
            $dynamicAlert['tp2'] ?? '',
            $dynamicAlert['tp3'] ?? '',
            $market['closed_candle_time'] ?? '',
        ]));
    }

    private static function shouldThrottle(string $type, int $minutes): bool
    {
        if (self::developerSimulationEnabled($alert)) {
            return false;
        }

        return $minutes > 0;
    }

    private static function hasRecentLog(int $alertId, string $channel, string $stage, string $signature, int $minutes): bool
    {
        try {
            $stmt = db()->prepare("
                SELECT COUNT(*)
                FROM institutional_alert_logs
                WHERE alert_id = ?
                  AND channel = ?
                  AND stage = ?
                  AND signature = ?
                  AND created_at >= DATE_SUB(NOW(), INTERVAL ? MINUTE)
            ");
            $stmt->execute([$alertId, $channel, $stage, $signature, $minutes]);

            return (int)$stmt->fetchColumn() > 0;
        } catch (Throwable $e) {
            return false;
        }
    }

    private static function shouldSendStageChannel(array $alert, string $channel, string $stage, string $signature): bool
    {
        if (self::stageAlreadySent($alert, $stage)) {
            self::recordDuplicateBlock((int)$alert['id'], 'stage_already_sent', $stage, $signature);
            return false;
        }

        if (self::alertInCooldown($alert)) {
            self::recordDuplicateBlock((int)$alert['id'], 'cooldown_active', $stage, $signature);
            return false;
        }

        $minutes = (int)(self::getSetting('stage_alert_throttle_minutes') ?: 30);

        if (self::hasRecentLog((int)$alert['id'], $channel, $stage, $signature, $minutes)) {
            self::recordDuplicateBlock((int)$alert['id'], 'stage_recent_duplicate_log', $stage, $signature);
            return false;
        }

        return true;
    }

    private static function shouldSendFinalChannel(array $alert, string $channel, string $signature): bool
    {
        if (!empty($alert['final_sent_at'])) {
            self::recordDuplicateBlock((int)$alert['id'], 'final_already_sent', 'FINAL_READY', $signature);
            return false;
        }

        if (self::alertInCooldown($alert)) {
            self::recordDuplicateBlock((int)$alert['id'], 'cooldown_active', 'FINAL_READY', $signature);
            return false;
        }

        if (!empty($alert['last_alert_fingerprint']) && hash_equals((string)$alert['last_alert_fingerprint'], (string)$signature)) {
            self::recordDuplicateBlock((int)$alert['id'], 'same_final_fingerprint', 'FINAL_READY', $signature);
            return false;
        }

        $minutes = (int)(self::getSetting('final_alert_throttle_minutes') ?: 240);

        if (self::hasRecentLog((int)$alert['id'], $channel, 'FINAL_READY', $signature, $minutes)) {
            self::recordDuplicateBlock((int)$alert['id'], 'final_recent_duplicate_log', 'FINAL_READY', $signature);
            return false;
        }

        return true;
    }

    private static function alertInCooldown(array $alert): bool
    {
        if (empty($alert['cooldown_until'])) {
            return false;
        }

        $until = strtotime((string)$alert['cooldown_until']);

        if (!$until) {
            return false;
        }

        return $until > time();
    }

    private static function stageAlreadySent(array $alert, string $stage): bool
    {
        if ($stage === 'ZONE_APPROACHED') {
            return !empty($alert['stage1_sent_at']) || ((int)($alert['stage1_sent_ts'] ?? 0) > 0);
        }

        if ($stage === 'LIQUIDITY_SWEPT') {
            return !empty($alert['stage2_sent_at']) || ((int)($alert['stage2_sent_ts'] ?? 0) > 0);
        }

        if ($stage === 'STRUCTURE_CONFIRMED' || $stage === 'READY_TO_EXECUTE') {
            return !empty($alert['stage3_sent_at']) || ((int)($alert['stage3_sent_ts'] ?? 0) > 0);
        }

        return false;
    }

    private static function markStageSent(int $alertId, string $stage, string $signature, array $market = []): void
    {
        try {
            $column = null;
            $tsColumn = null;
            $eventTsColumn = null;
            $eventTsValue = null;

            if ($stage === 'ZONE_APPROACHED') {
                $column = 'stage1_sent_at';
                $tsColumn = 'stage1_sent_ts';
                $eventTsColumn = 'stage1_event_ts';
                $eventTsValue = (int)($market['zone_event_ts'] ?? 0);
            }

            if ($stage === 'LIQUIDITY_SWEPT') {
                $column = 'stage2_sent_at';
                $tsColumn = 'stage2_sent_ts';
                $eventTsColumn = 'stage2_event_ts';
                $eventTsValue = (int)($market['sweep_event_ts'] ?? 0);
            }

            if ($stage === 'STRUCTURE_CONFIRMED' || $stage === 'READY_TO_EXECUTE') {
                $column = 'stage3_sent_at';
                $tsColumn = 'stage3_sent_ts';
                $eventTsColumn = 'stage3_event_ts';
                $eventTsValue = (int)($market['structure_event_ts'] ?? 0);
            }

            if (!$column || !$tsColumn || !$eventTsColumn) {
                return;
            }

            $nowTs = time();
            $eventTsValue = $eventTsValue > 0 ? $eventTsValue : $nowTs;
            $cooldownMinutes = (int)(self::getSetting('stage_cooldown_minutes') ?: 5);

            db()->prepare("
                UPDATE institutional_alerts
                SET {$column} = COALESCE({$column}, NOW()),
                    {$tsColumn} = COALESCE({$tsColumn}, ?),
                    {$eventTsColumn} = COALESCE({$eventTsColumn}, ?),
                    last_stage_signature = ?,
                    last_stage_sent_at = NOW(),
                    last_alert_fingerprint = ?,
                    cooldown_until = DATE_ADD(NOW(), INTERVAL ? MINUTE),
                    stage_event_guard = 'EVENT_SEQUENCE_CONFIRMED',
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([$nowTs, $eventTsValue, $signature, $signature, $cooldownMinutes, $alertId]);
        } catch (Throwable $e) {
        }
    }



    private static function markFinalSentAdvanced(int $alertId, string $signature): void
    {
        try {
            $cooldownMinutes = (int)(self::getSetting('final_cooldown_minutes') ?: 240);

            db()->prepare("
                UPDATE institutional_alerts
                SET final_sent_at = COALESCE(final_sent_at, NOW()),
                    last_final_signature = ?,
                    last_alert_fingerprint = ?,
                    last_final_attempt_at = NOW(),
                    cooldown_until = DATE_ADD(NOW(), INTERVAL ? MINUTE),
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([$signature, $signature, $cooldownMinutes, $alertId]);
        } catch (Throwable $e) {
        }
    }

    private static function recordDuplicateBlock(int $alertId, string $reason, string $stage, string $signature): void
    {
        try {
            db()->prepare("
                UPDATE institutional_alerts
                SET duplicate_block_count = COALESCE(duplicate_block_count, 0) + 1,
                    last_duplicate_blocked_at = NOW(),
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([$alertId]);

            db()->prepare("
                INSERT INTO institutional_alert_logs
                (alert_id, user_id, channel, status, message, signature, stage)
                SELECT id, user_id, 'duplicate_guard', 'blocked', ?, ?, ?
                FROM institutional_alerts
                WHERE id = ?
                LIMIT 1
            ")->execute([$reason, $signature, $stage, $alertId]);
        } catch (Throwable $e) {
        }
    }

    private static function lifecycleRank(string $stage): int
    {
        $order = [
            'TRACKING' => 0,
            'ZONE_APPROACHED' => 1,
            'LIQUIDITY_SWEPT' => 2,
            'STRUCTURE_CONFIRMED' => 3,
            'READY_TO_EXECUTE' => 4,
            'ALERT_SENT' => 5,
            'EXPIRED' => 6,
        ];

        return $order[$stage] ?? 0;
    }

    private static function stageDeliveryState(array $alert, string $currentStage): array
    {
        $currentRank = self::lifecycleRank($currentStage);

        $stageMap = [
            'ZONE_APPROACHED' => [
                'rank' => 1,
                'label' => 'Stage 1: Zone Approached',
                'sent_column' => 'stage1_sent_at',
            ],
            'LIQUIDITY_SWEPT' => [
                'rank' => 2,
                'label' => 'Stage 2: Liquidity Swept',
                'sent_column' => 'stage2_sent_at',
            ],
            'STRUCTURE_CONFIRMED' => [
                'rank' => 3,
                'label' => 'Stage 3: Structure Confirmed',
                'sent_column' => 'stage3_sent_at',
            ],
        ];

        $items = [];
        $required = 0;
        $alreadySent = 0;
        $pending = 0;

        foreach ($stageMap as $stage => $meta) {
            $isRequired = $currentRank >= (int)$meta['rank'];

            if (!$isRequired) {
                continue;
            }

            $required++;
            $sentAt = $alert[$meta['sent_column']] ?? null;
            $isSent = !empty($sentAt);

            if ($isSent) {
                $alreadySent++;
            } else {
                $pending++;
            }

            $items[] = [
                'stage' => $stage,
                'label' => $meta['label'],
                'required' => true,
                'already_sent' => $isSent,
                'sent_at' => $sentAt,
            ];
        }

        return [
            'current_stage' => $currentStage,
            'required' => $required,
            'already_sent' => $alreadySent,
            'pending' => $pending,
            'fully_delivered' => $required > 0 && $pending === 0,
            'status' => $required === 0
                ? 'tracking'
                : ($pending === 0 ? 'already_delivered' : 'pending_stage_delivery'),
            'items' => $items,
        ];
    }

    private static function unsentStagesUpToCurrentProgress(array $alert, string $currentStage): array
    {
        $currentRank = self::lifecycleRank($currentStage);

        if ($currentRank <= 0) {
            return [];
        }

        $orderedStages = [
            'ZONE_APPROACHED' => 1,
            'LIQUIDITY_SWEPT' => 2,
            'STRUCTURE_CONFIRMED' => 3,
        ];

        $missing = [];

        foreach ($orderedStages as $stage => $rank) {
            if ($rank <= $currentRank && !self::stageAlreadySent($alert, $stage)) {
                $missing[] = $stage;
            }
        }

        return $missing;
    }

    private static function stageSequenceBetween(string $oldStage, string $newStage): array
    {
        $oldRank = self::lifecycleRank($oldStage);
        $newRank = self::lifecycleRank($newStage);

        if ($newRank <= $oldRank) {
            return [];
        }

        $order = [
            'ZONE_APPROACHED' => 1,
            'LIQUIDITY_SWEPT' => 2,
            'STRUCTURE_CONFIRMED' => 3,
        ];

        $stages = [];

        foreach ($order as $stage => $rank) {
            if ($rank > $oldRank && $rank <= $newRank) {
                $stages[] = $stage;
            }
        }

        return $stages;
    }


    private static function renderStringTemplate(string $template, array $data): string
    {
        foreach ($data as $k => $v) {
            $template = str_replace('{{' . $k . '}}', (string)$v, $template);
        }

        return self::stripExecutionChecklistPlaceholders($template);
    }

    private static function sendStageAlert(array $alert, array $market, array $evaluation): array
    {
        $stage = (string)($evaluation['lifecycle_stage'] ?? '');

        $result = [
            'stage' => $stage,
            'attempted' => false,
            'delivered' => false,
            'telegram' => null,
            'email' => null,
            'skipped_reason' => null,
        ];

        if (!self::settingEnabled('send_stage_alerts', true)) {
            $result['skipped_reason'] = 'stage_alerts_disabled';
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'stage_setting', 'skipped', 'Stage alerts disabled globally.', null, $stage);
            return $result;
        }

        if (
            ($stage === 'ZONE_APPROACHED' && !self::settingEnabled('stage_1_zone_alert', true)) ||
            ($stage === 'LIQUIDITY_SWEPT' && !self::settingEnabled('stage_2_liquidity_alert', true)) ||
            (($stage === 'STRUCTURE_CONFIRMED' || $stage === 'READY_TO_EXECUTE') && !self::settingEnabled('stage_3_bos_alert', true))
        ) {
            $result['skipped_reason'] = 'stage_disabled_by_admin_setting';
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'stage_admin_setting', 'skipped', 'Stage disabled by alert engine setting: ' . $stage, null, $stage);
            return $result;
        }

        if (!self::stageNotificationEnabled($alert, $stage)) {
            $result['skipped_reason'] = 'stage_disabled_by_user_preference';
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'stage_preference', 'skipped', 'User disabled notification for ' . $stage, null, $stage);
            return $result;
        }

        $telegramTemplateKey = '';
        $emailTemplateKey = '';
        $emailSubjectTemplate = '';

        if ($stage === 'ZONE_APPROACHED') {
            $telegramTemplateKey = 'stage_zone_approached_telegram';
            $emailTemplateKey = 'stage_zone_approached_email';
            $emailSubjectTemplate = 'Stage 1: Zone Approached - {{symbol}} {{side}}';
        }

        if ($stage === 'LIQUIDITY_SWEPT') {
            $telegramTemplateKey = 'stage_liquidity_swept_telegram';
            $emailTemplateKey = 'stage_liquidity_swept_email';
            $emailSubjectTemplate = 'Stage 2: Liquidity Sweep - {{symbol}} {{side}}';
        }

        if ($stage === 'STRUCTURE_CONFIRMED' || $stage === 'READY_TO_EXECUTE') {
            $telegramTemplateKey = 'stage_structure_confirmed_telegram';
            $emailTemplateKey = 'stage_structure_confirmed_email';
            $emailSubjectTemplate = 'Stage 3: Structure Confirmed - {{symbol}} {{side}}';
        }

        if (!$telegramTemplateKey && !$emailTemplateKey) {
            $result['skipped_reason'] = 'no_stage_template_key';
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'stage_template', 'skipped', 'No template configured for stage: ' . $stage, null, $stage);
            return $result;
        }

        $newsWarning = self::newsWarningForAlert($alert);

        $evaluation['news_warning'] = $newsWarning['message'];
        $evaluation['news_risk'] = $newsWarning['risk'];
        $evaluation['news_title'] = $newsWarning['title'];
        $evaluation['news_time'] = $newsWarning['time'];
        $evaluation['news_countdown'] = $newsWarning['countdown'];

        $stageChecklist = self::finalExecutionChecklist($alert, $market, $evaluation, null, $newsWarning);
        $evaluation['execution_checklist'] = $stageChecklist;
        $evaluation['execution_checklist_plain'] = self::executionChecklistPlain($stageChecklist);
        $evaluation['execution_checklist_telegram'] = self::executionChecklistTelegram($stageChecklist);
        $evaluation['execution_checklist_html'] = self::executionChecklistHtml($stageChecklist);
        $evaluation['execution_checklist_status'] = $stageChecklist['status'] ?? 'PENDING';
        self::saveFinalExecutionChecklist((int)$alert['id'], $stageChecklist);

        $data = self::templateData($alert, $market, $evaluation);
        $notificationData = self::notificationTemplateData($data);
        $signature = self::stageSignature($alert, $stage, $market);
        $stageDelivered = false;

        $telegramConfigured = $telegramTemplateKey && !empty($alert['telegram_enabled']) && !empty($alert['telegram_chat_id']);
        $emailConfigured = $emailTemplateKey && !empty($alert['email_enabled']) && !empty($alert['email']);

        if (!$telegramConfigured && !$emailConfigured) {
            $result['skipped_reason'] = 'no_enabled_delivery_channel_for_stage';
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'stage_delivery', 'skipped', 'No enabled Telegram or email delivery channel for stage alert.', $signature, $stage);
            return $result;
        }

        $result['attempted'] = true;

        if ($telegramConfigured) {
            if (self::shouldSendStageChannel($alert, 'telegram_stage', $stage, $signature)) {
                $telegramMessage = self::appendNewsWarningToTelegram(self::stripExecutionChecklistPlaceholders(self::renderProfessionalAlertTemplate($telegramTemplateKey, $notificationData, 'telegram', $stage)), $newsWarning);
                $telegramSent = self::sendTelegram($telegramMessage, $alert['telegram_chat_id']);

                $result['telegram'] = $telegramSent ? 'sent' : 'failed';

                self::logAlert(
                    (int)$alert['id'],
                    (int)$alert['user_id'],
                    'telegram_stage',
                    $telegramSent ? 'sent' : 'failed',
                    $telegramMessage,
                    $signature,
                    $stage
                );

                if ($telegramSent) {
                    $stageDelivered = true;
                }
            } else {
                $result['telegram'] = 'blocked';
            }
        } else {
            $result['telegram'] = 'not_configured';
        }

        if ($emailConfigured) {
            if (self::shouldSendStageChannel($alert, 'email_stage', $stage, $signature)) {
                $emailSubject = self::renderStringTemplate($emailSubjectTemplate, $notificationData);
                $emailBody = self::appendNewsWarningToEmail(self::stripExecutionChecklistPlaceholders(self::renderProfessionalAlertTemplate($emailTemplateKey, $notificationData, 'email', $stage)), $newsWarning);
                $emailSent = self::sendEmail($alert['email'], $emailSubject, $emailBody);

                $result['email'] = $emailSent ? 'sent' : 'failed';

                self::logAlert(
                    (int)$alert['id'],
                    (int)$alert['user_id'],
                    'email_stage',
                    $emailSent ? 'sent' : 'failed',
                    $emailBody,
                    $signature,
                    $stage
                );

                if ($emailSent) {
                    $stageDelivered = true;
                }
            } else {
                $result['email'] = 'blocked';
            }
        } else {
            $result['email'] = 'not_configured';
        }

        if ($stageDelivered) {
            self::markStageSent((int)$alert['id'], $stage, $signature, $market);
            $result['delivered'] = true;
        } else {
            $result['skipped_reason'] = $result['skipped_reason'] ?: 'delivery_failed_or_blocked';
        }

        return $result;
    }

    private static function dispatchFinalAlert(array $alert, array $market, array $evaluation): array
    {
        $newsWarning = self::newsWarningForAlert($alert);

        if (!self::settingEnabled('stage_4_execution_alert', true)) {
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'final_admin_setting', 'skipped', 'Final execution alert disabled by alert engine settings.', null, 'FINAL_READY');

            return [
                'alert_id' => (int)$alert['id'],
                'ready' => true,
                'sent' => false,
                'stage' => 'READY_TO_EXECUTE',
                'message' => 'Final setup ready, but final execution alert is disabled by alert engine settings.'
            ];
        }

        if (!self::userAllowsFinalSetup($alert)) {
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'final_preference', 'skipped', 'User disabled final setup notifications.', null, 'FINAL_READY');

            return [
                'alert_id' => (int)$alert['id'],
                'ready' => true,
                'sent' => false,
                'stage' => 'READY_TO_EXECUTE',
                'message' => 'Final setup ready, but final notification is disabled by user preference.'
            ];
        }

        if (!self::newsPreferenceAllowsFinal($alert, $newsWarning)) {
            $reason = self::finalPreferenceBlockReason($alert, $newsWarning);
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'news_preference', 'waiting', $reason, null, 'FINAL_READY');

            return [
                'alert_id' => (int)$alert['id'],
                'ready' => true,
                'sent' => false,
                'stage' => 'READY_TO_EXECUTE',
                'message' => $reason
            ];
        }

        $marketGuard = self::finalMarketReadinessGuard($alert, $market);
        if (empty($marketGuard['passed'])) {
            $reason = $marketGuard['reason'] ?? 'Stage 3 confirmed. Waiting for market reopen and fresh final confirmation.';
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'market_open_guard', 'waiting', $reason, null, 'FINAL_READY');

            $waitingEvaluation = $evaluation;
            $waitingEvaluation['message'] = $reason;
            $waitingEvaluation['final_market_guard_passed'] = false;
            $waitingEvaluation['final_market_guard_reason'] = $reason;
            self::markChecked((int)$alert['id'], 'STRUCTURE_CONFIRMED', $market, $waitingEvaluation);

            return [
                'alert_id' => (int)$alert['id'],
                'ready' => false,
                'sent' => false,
                'stage' => 'STRUCTURE_CONFIRMED',
                'final_waiting_for_market' => true,
                'message' => $reason
            ];
        }

        /*
        |--------------------------------------------------------------------------
        | Dynamic execution recalculation
        |--------------------------------------------------------------------------
        | This is the key fix:
        | final alerts no longer blindly send the stale entry/SL/TP stored when
        | the user clicked Track Setup. They now recalculate after confirmation.
        |--------------------------------------------------------------------------
        */

        $execution = DynamicExecutionService::buildFromAlert($alert, $market);
        $execution = is_array($execution) ? $execution : [];

        $zoneAssessment = self::finalExecutionZoneAssessment($alert, $execution);
        if (!empty($zoneAssessment['outside_tracked_zone'])) {
            $suggestion = self::maybeCreateDerivedSuggestedSetup($alert, $market, $evaluation, $execution, $zoneAssessment, $newsWarning);
            $reason = 'Final alert was not delivered because the recalculated entry no longer belongs to the originally tracked institutional zone.';

            if (!empty($suggestion['created']) || !empty($suggestion['existing'])) {
                $reason .= ' A new suggested setup has been saved for review instead of replacing the original tracked setup.';
            } else {
                $reason .= ' No suggested setup was created because the candidate did not pass all derived-setup protection checks.';
                if (!empty($suggestion['reason'])) {
                    $reason .= ' ' . (string)$suggestion['reason'];
                }
            }

            self::logAlert(
                (int)$alert['id'],
                (int)$alert['user_id'],
                'suggested_setup',
                (!empty($suggestion['created']) || !empty($suggestion['existing'])) ? 'created' : 'waiting',
                $reason,
                null,
                'DERIVED_SETUP'
            );

            $derivedEvaluation = $evaluation;
            $derivedEvaluation['message'] = $reason;
            $derivedEvaluation['derived_setup'] = $suggestion;
            $derivedEvaluation['outside_tracked_zone'] = true;
            $derivedEvaluation['zone_integrity'] = $zoneAssessment;
            self::markChecked((int)$alert['id'], 'STRUCTURE_CONFIRMED', $market, $derivedEvaluation);

            return [
                'alert_id' => (int)$alert['id'],
                'ready' => false,
                'sent' => false,
                'stage' => 'STRUCTURE_CONFIRMED',
                'final_waiting_for_user_review' => true,
                'derived_setup_suggested' => !empty($suggestion['created']) || !empty($suggestion['existing']),
                'suggested_setup_id' => $suggestion['id'] ?? null,
                'message' => $reason,
            ];
        }

        $execution = self::sanitizeFinalExecutionPlan($alert, $execution);

        $dynamicAlert = $alert;
        $dynamicAlert['entry_price'] = $execution['entry_price'] ?? $alert['entry_price'];
        $dynamicAlert['entry_low'] = $execution['entry_low'] ?? $alert['entry_low'];
        $dynamicAlert['entry_high'] = $execution['entry_high'] ?? $alert['entry_high'];
        $dynamicAlert['stop_loss'] = $execution['stop_loss'] ?? $alert['stop_loss'];
        $dynamicAlert['tp1'] = $execution['tp1'] ?? $alert['tp1'];
        $dynamicAlert['tp2'] = $execution['tp2'] ?? $alert['tp2'];
        $dynamicAlert['tp3'] = $execution['tp3'] ?? $alert['tp3'];

        $market['current_price'] = $execution['current_price'] ?? $market['current_price'] ?? null;

        $evaluation['message'] .= "\n\nDynamic execution recalculated after liquidity sweep and BOS/CHoCH confirmation.";

        if (!empty($execution['dynamic'])) {
            $evaluation['message'] .= "\nExecution Source: LIVE post-confirmation recalculation.";
        } else {
            $evaluation['message'] .= "\nExecution Source: Stored fallback setup.";
        }

        $newsWarning = self::newsWarningForAlert($alert);
        $evaluation['news_warning'] = $newsWarning['message'];
        $evaluation['news_risk'] = $newsWarning['risk'];
        $evaluation['news_title'] = $newsWarning['title'];
        $evaluation['news_time'] = $newsWarning['time'];
        $evaluation['news_countdown'] = $newsWarning['countdown'];

        $checklist = self::finalExecutionChecklist($dynamicAlert, $market, $evaluation, $execution, $newsWarning);
        $evaluation['execution_checklist'] = $checklist;
        $evaluation['execution_checklist_plain'] = self::executionChecklistPlain($checklist);
        $evaluation['execution_checklist_telegram'] = self::executionChecklistTelegram($checklist);
        $evaluation['execution_checklist_html'] = self::executionChecklistHtml($checklist);
        $evaluation['execution_checklist_status'] = $checklist['status'] ?? 'UNKNOWN';
        self::saveFinalExecutionChecklist((int)$alert['id'], $checklist);
        $evaluation['risk_reward_user_advisory'] = self::riskRewardUserAdvisory($checklist);

        $finalSignature = self::finalSignature($alert, $dynamicAlert, $market);

        if (empty($checklist['passed'])) {
            $reason = 'Final execution is waiting for final readiness. ' . self::checklistFailureSummary($checklist);
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'execution_checklist', 'waiting', $reason . "\n\n" . self::executionChecklistPlain($checklist), $finalSignature, 'FINAL_READY');

            $blockedEvaluation = $evaluation;
            $blockedEvaluation['message'] = $reason;
            self::markChecked((int)$alert['id'], 'STRUCTURE_CONFIRMED', $market, $blockedEvaluation);

            return [
                'alert_id' => (int)$alert['id'],
                'ready' => false,
                'sent' => false,
                'stage' => 'STRUCTURE_CONFIRMED',
                'final_waiting_for_readiness' => true,
                'message' => $reason,
                'execution_checklist_status' => $checklist['status'] ?? 'FAILED',
                'execution_checklist' => $checklist,
            ];
        }

        $data = self::templateData($dynamicAlert, $market, $evaluation);
        $notificationData = self::notificationTemplateData($data);

        $telegram = false;
        $email = false;

        if (empty($alert['notify_final_setup'])) {
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'final_preference', 'skipped', 'User disabled final setup notification.', $finalSignature, 'FINAL_READY');
            self::markFinalSentAdvanced((int)$alert['id'], $finalSignature);
            self::markTriggered((int)$alert['id']);

            return [
                'alert_id' => (int)$alert['id'],
                'ready' => true,
                'telegram' => false,
                'email' => false,
                'skipped_by_user_preference' => true
            ];
        }

        if (
            !empty($alert['telegram_enabled']) &&
            !empty($alert['telegram_chat_id']) &&
            self::shouldSendFinalChannel($alert, 'telegram', $finalSignature)
        ) {
            $msg = self::appendNewsWarningToTelegram(self::stripExecutionChecklistPlaceholders(self::renderProfessionalAlertTemplate('institutional_alert_telegram_body', $notificationData, 'telegram', 'READY_TO_EXECUTE')), $newsWarning);

            $msg .= "\n\n<b>Execution Engine:</b> ";
            $msg .= !empty($execution['dynamic'])
                ? "Dynamic institutional recalculation"
                : "Stored fallback execution";

            if (!empty($execution['risk_distance'])) {
                $msg .= "\n<b>Risk Distance:</b> " . $execution['risk_distance'];
            }

            if (!empty($evaluation['risk_reward_user_advisory'])) {
                $msg .= "\n<b>Risk/Reward:</b> " . htmlspecialchars((string)$evaluation['risk_reward_user_advisory'], ENT_QUOTES, 'UTF-8');
            }

            if (!empty($execution['atr'])) {
                $msg .= "\n<b>ATR:</b> " . $execution['atr'];
            }

            if (!empty($execution['reason'])) {
                $msg .= "\n<b>Reason:</b> " . htmlspecialchars((string)$execution['reason'], ENT_QUOTES, 'UTF-8');
            }

            $msg .= "\n\n<b>Quality Score:</b> " . ($evaluation['quality_score'] ?? 'N/A');
            $msg .= "\n<b>Session:</b> " . ($evaluation['session_name'] ?? 'N/A');
            $msg .= "\n<b>Volatility:</b> " . ($evaluation['volatility_status'] ?? 'N/A');
            $msg .= "\n<b>MTF:</b> " . ($evaluation['mtf_status'] ?? 'N/A');
            $msg .= "\n<b>MTF Score:</b> " . ($evaluation['mtf_score'] ?? 'N/A');
            $msg .= "\n<b>MTF Strictness:</b> " . ($evaluation['mtf_strictness'] ?? 'N/A');
            $msg .= "\n<b>Candle Close:</b> " . ($evaluation['candle_close_status'] ?? 'N/A');

            $telegram = self::sendTelegram($msg, $alert['telegram_chat_id']);
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'telegram', $telegram ? 'sent' : 'failed', $msg, $finalSignature, 'FINAL_READY');
        }

        if (
            !empty($alert['email_enabled']) &&
            !empty($alert['email']) &&
            self::shouldSendFinalChannel($alert, 'email', $finalSignature)
        ) {
            $subject = self::renderTemplate('institutional_alert_email_subject', $notificationData);
            $body = self::stripExecutionChecklistPlaceholders(self::renderProfessionalAlertTemplate('institutional_alert_email_body', $notificationData, 'email', 'READY_TO_EXECUTE'));
            $body = self::appendFinalExecutionDetailsToEmail($body, $execution, $evaluation);
            $body = self::appendNewsWarningToEmail($body, $newsWarning);

            $email = self::sendEmail($alert['email'], $subject, $body);
            self::logAlert((int)$alert['id'], (int)$alert['user_id'], 'email', $email ? 'sent' : 'failed', $body, $finalSignature, 'FINAL_READY');
        }

        if ($telegram || $email) {
            self::markFinalSentAdvanced((int)$alert['id'], $finalSignature);
            self::markTriggered((int)$alert['id']);
        }

        return [
            'alert_id' => (int)$alert['id'],
            'ready' => true,
            'telegram' => $telegram,
            'email' => $email,
            'dynamic_execution' => !empty($execution['dynamic']),
            'execution_source' => $execution['source'] ?? 'unknown',
            'execution_reason' => $execution['reason'] ?? null,
            'entry_price' => $dynamicAlert['entry_price'] ?? null,
            'entry_low' => $dynamicAlert['entry_low'] ?? null,
            'entry_high' => $dynamicAlert['entry_high'] ?? null,
            'stop_loss' => $dynamicAlert['stop_loss'] ?? null,
            'tp1' => $dynamicAlert['tp1'] ?? null,
            'tp2' => $dynamicAlert['tp2'] ?? null,
            'tp3' => $dynamicAlert['tp3'] ?? null
        ];
    }

    private static function getRecentCandles(string $symbol, string $timeframe, int $limit = 30): array
    {
        try {
            $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim($symbol)));
            $timeframe = self::normalizeAlertTimeframe($timeframe);

            $stmt = db()->prepare("
                SELECT *
                FROM candles
                WHERE UPPER(symbol) = UPPER(?)
                  AND UPPER(timeframe) = UPPER(?)
                ORDER BY open_time DESC
                LIMIT ?
            ");
            $stmt->bindValue(1, $symbol);
            $stmt->bindValue(2, $timeframe);
            $stmt->bindValue(3, $limit, PDO::PARAM_INT);
            $stmt->execute();

            return array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
        } catch (Throwable $e) {
            return [];
        }
    }

    public static function sendTelegram($message, $chatId): bool
    {
        $token = self::getSetting('telegram_bot_token');

        if (!$message || !$chatId || !$token) {
            return false;
        }

        try {
            $ch = curl_init("https://api.telegram.org/bot{$token}/sendMessage");
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => http_build_query([
                    'chat_id' => $chatId,
                    'text' => $message,
                    'parse_mode' => 'HTML',
                    'disable_web_page_preview' => true
                ]),
                CURLOPT_TIMEOUT => 20,
                CURLOPT_SSL_VERIFYPEER => true
            ]);

            $response = curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            $json = json_decode((string)$response, true);

            return $code === 200 && !empty($json['ok']);
        } catch (Throwable $e) {
            return false;
        }
    }

    public static function sendEmail($to, $subject, $message): bool
    {
        if (!$to || !$subject || !$message) {
            return false;
        }

        $from = self::getSetting('system_email') ?: 'alerts@yourdomain.com';
        $fromName = self::getSetting('system_from_name') ?: 'Trading Assistant';

        $htmlMessage = self::formatEmailBody((string)$message);
        $plainMessage = self::htmlToPlainText((string)$message);

        if (class_exists('PHPMailer\\PHPMailer\\PHPMailer') && self::getSetting('smtp_host')) {
            try {
                $mail = new PHPMailer(true);
                $mail->isSMTP();
                $mail->Host = self::getSetting('smtp_host');
                $mail->SMTPAuth = true;
                $mail->Username = self::getSetting('smtp_username');
                $mail->Password = self::getSetting('smtp_password');
                $mail->Port = (int)(self::getSetting('smtp_port') ?: 587);

                $enc = self::getSetting('smtp_encryption');
                if ($enc) {
                    $mail->SMTPSecure = $enc;
                }

                $mail->setFrom($from, $fromName);
                $mail->addAddress($to);

                /*
                |--------------------------------------------------------------------------
                | HTML EMAIL ENABLED
                |--------------------------------------------------------------------------
                | This preserves the original sendEmail() flow but sends a rich HTML
                | message. A plain-text fallback is also included for mail clients
                | that do not render HTML.
                |--------------------------------------------------------------------------
                */

                $mail->isHTML(true);
                $mail->CharSet = 'UTF-8';
                $mail->Subject = $subject;
                $mail->Body = $htmlMessage;
                $mail->AltBody = $plainMessage;

                return $mail->send();
            } catch (Throwable $e) {
                return false;
            }
        }

        $headers = [
            "From: {$fromName} <{$from}>",
            "MIME-Version: 1.0",
            "Content-Type: text/html; charset=UTF-8"
        ];

        return mail($to, $subject, $htmlMessage, implode("\r\n", $headers));
    }


    private static function formatEmailBody(string $message): string
    {
        $trimmed = trim($message);

        /*
        |--------------------------------------------------------------------------
        | If the saved template is already a complete HTML email or table/card
        | template, send it as-is. Otherwise, wrap plain text in a professional
        | HTML shell.
        |--------------------------------------------------------------------------
        */

        if (
    preg_match('/<\s*(html|body|table|div|h1|h2|h3|p|span|br|strong|b)\b/i', $trimmed)
    ) {
        return $trimmed;
    }

        $safe = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
        $safe = nl2br($safe);

        return self::emailShell($safe);
    }

    private static function emailShell(string $content): string
    {
        return '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Institutional Alert</title>
</head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:Arial,sans-serif;color:#0f172a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9;padding:28px 12px;">
<tr>
<td align="center">
<table role="presentation" width="680" cellpadding="0" cellspacing="0" style="max-width:680px;background:#ffffff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0;">
<tr>
<td style="background:#0f172a;padding:26px 30px;color:#ffffff;">
<div style="font-size:26px;font-weight:800;letter-spacing:-0.02em;">Institutional Trading Alert</div>
<div style="margin-top:7px;color:#cbd5e1;font-size:14px;">Smart Money execution notification</div>
</td>
</tr>
<tr>
<td style="padding:30px;font-size:15px;line-height:1.75;color:#111827;">' . $content . '</td>
</tr>
<tr>
<td style="padding:20px 30px;background:#f8fafc;border-top:1px solid #e2e8f0;color:#64748b;font-size:12px;line-height:1.6;">
This alert was generated automatically. Always apply proper risk management. This is not financial advice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>';
    }

    private static function htmlToPlainText(string $message): string
    {
        $text = preg_replace('/<br\s*\/?>/i', "\n", $message);
        $text = preg_replace('/<\/p>/i', "\n\n", (string)$text);
        $text = strip_tags((string)$text);
        return html_entity_decode($text, ENT_QUOTES, 'UTF-8');
    }

    private static function defaultHtmlEmailTemplate(): string
    {
        return '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Institutional Setup Ready</title>
</head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:Arial,sans-serif;color:#0f172a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9;padding:28px 12px;">
<tr>
<td align="center">
<table role="presentation" width="680" cellpadding="0" cellspacing="0" style="max-width:680px;background:#ffffff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0;">
<tr>
<td style="background:#0f172a;padding:26px 30px;color:#ffffff;">
<div style="font-size:26px;font-weight:800;letter-spacing:-0.02em;">Institutional Setup Ready</div>
<div style="margin-top:7px;color:#cbd5e1;font-size:14px;">{{symbol}} • {{side}} • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}</div>
</td>
</tr>
<tr>
<td style="padding:30px;">
<p style="margin:0 0 16px;font-size:15px;line-height:1.7;">Hi <strong>{{name}}</strong>,</p>
<p style="margin:0 0 22px;font-size:15px;line-height:1.7;">Your tracked institutional setup is now ready.</p>

<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
<tr><td style="padding:13px 16px;background:#f8fafc;color:#64748b;font-weight:bold;">Symbol</td><td style="padding:13px 16px;background:#f8fafc;font-weight:bold;">{{symbol}}</td></tr>
<tr><td style="padding:13px 16px;color:#64748b;font-weight:bold;">Direction</td><td style="padding:13px 16px;font-weight:bold;">{{side}}</td></tr>
<tr><td style="padding:13px 16px;background:#f8fafc;color:#64748b;font-weight:bold;">Setup TF</td><td style="padding:13px 16px;background:#f8fafc;">{{setup_timeframe}}</td></tr>
<tr><td style="padding:13px 16px;color:#64748b;font-weight:bold;">Confirm TF</td><td style="padding:13px 16px;">{{confirmation_timeframe}}</td></tr>
<tr><td style="padding:13px 16px;color:#64748b;font-weight:bold;">Current Price</td><td style="padding:13px 16px;">{{current_price}}</td></tr>
<tr><td style="padding:13px 16px;background:#f8fafc;color:#64748b;font-weight:bold;">Entry Zone</td><td style="padding:13px 16px;background:#f8fafc;">{{entry_low}} - {{entry_high}}</td></tr>
<tr><td style="padding:13px 16px;color:#64748b;font-weight:bold;">Suggested Entry</td><td style="padding:13px 16px;font-weight:bold;">{{entry_price}}</td></tr>
<tr><td style="padding:13px 16px;background:#f8fafc;color:#64748b;font-weight:bold;">Stop Loss</td><td style="padding:13px 16px;background:#f8fafc;color:#dc2626;font-weight:bold;">{{stop_loss}}</td></tr>
<tr><td style="padding:13px 16px;color:#64748b;font-weight:bold;">TP1</td><td style="padding:13px 16px;color:#16a34a;font-weight:bold;">{{tp1}}</td></tr>
<tr><td style="padding:13px 16px;background:#f8fafc;color:#64748b;font-weight:bold;">TP2</td><td style="padding:13px 16px;background:#f8fafc;color:#16a34a;font-weight:bold;">{{tp2}}</td></tr>
<tr><td style="padding:13px 16px;color:#64748b;font-weight:bold;">TP3</td><td style="padding:13px 16px;color:#16a34a;font-weight:bold;">{{tp3}}</td></tr>
</table>

<div style="margin-top:22px;padding:16px;background:#eff6ff;border-left:4px solid #2563eb;border-radius:10px;font-size:14px;line-height:1.7;">
<strong>Confirmed Conditions</strong><br>
• Price reached the institutional zone<br>
• Liquidity sweep detected<br>
• BOS / CHoCH confirmation detected<br><br>
<strong>Lifecycle Stage:</strong> {{lifecycle_stage}}
</div>

<p style="margin:24px 0 0;color:#64748b;font-size:12px;line-height:1.7;">
Important: This is not financial advice. Use your own risk management before execution.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>';
    }


    private static function defaultStageZoneEmailTemplate(): string
    {
        return self::emailShell(
            '<h2 style="margin-top:0;color:#2563eb;">Stage 1: Zone Approached</h2>
            <p><strong>{{symbol}} {{side}}</strong></p>
            <p>Price has approached the tracked institutional zone.</p>
            <p><strong>Current Price:</strong> {{current_price}}</p>
            <p><strong>Tracked Zone:</strong> {{entry_zone}}</p>
            <p>Next step: wait for a fresh liquidity sweep.</p>'
        );
    }

    private static function defaultStageSweepEmailTemplate(): string
    {
        return self::emailShell(
            '<h2 style="margin-top:0;color:#2563eb;">Stage 2: Liquidity Swept</h2>
            <p><strong>{{symbol}} {{side}}</strong></p>
            <p>Liquidity has been swept around the tracked zone.</p>
            <p><strong>Current Price:</strong> {{current_price}}</p>
            <p><strong>Tracked Zone:</strong> {{entry_zone}}</p>
            <p>Next step: wait for displacement / BOS / CHoCH confirmation.</p>'
        );
    }

    private static function defaultStageStructureEmailTemplate(): string
    {
        return self::emailShell(
            '<h2 style="margin-top:0;color:#2563eb;">Stage 3: Structure Confirmed</h2>
            <p><strong>{{symbol}} {{side}}</strong></p>
            <p>BOS/CHoCH structure confirmation has been detected.</p>
            <p><strong>Current Price:</strong> {{current_price}}</p>
            <p><strong>Tracked Zone:</strong> {{entry_zone}}</p>
            <p>Next step: wait for final candle confirmation and quality filters.</p>'
        );
    }


    private static function updateUserAlertPreferences(int $userId, array $post): void
    {
        try {
            $preferenceMap = [
                'avoid_news_trades' => !empty($post['avoid_news_trades']) ? 1 : 0,
                'trade_high_impact_news' => !empty($post['trade_high_impact_news']) ? 1 : 0,
                'trade_medium_impact_news' => !empty($post['trade_medium_impact_news']) ? 1 : 0,
                'news_block_minutes_before' => max(15, min(720, (int)($post['news_block_minutes_before'] ?? 60))),
                'news_block_minutes_after' => max(0, min(720, (int)($post['news_block_minutes_after'] ?? 30))),
                'notify_stage_1' => !empty($post['notify_stage_1']) ? 1 : 0,
                'notify_stage_2' => !empty($post['notify_stage_2']) ? 1 : 0,
                'notify_stage_3' => !empty($post['notify_stage_3']) ? 1 : 0,
                'notify_final_setup' => !empty($post['notify_final_setup']) ? 1 : 0,
            ];

            $hasPreferencePayload = false;
            foreach (array_keys($preferenceMap) as $key) {
                if (array_key_exists($key, $post)) {
                    $hasPreferencePayload = true;
                    break;
                }
            }

            if (!$hasPreferencePayload) {
                return;
            }

            $stmt = db()->prepare("SHOW COLUMNS FROM users");
            $stmt->execute();
            $columns = array_map('strtolower', $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []);

            $updates = [];
            $params = [];

            foreach ($preferenceMap as $column => $value) {
                if (in_array(strtolower($column), $columns, true)) {
                    $updates[] = $column . ' = ?';
                    $params[] = $value;
                }
            }

            if (!$updates) {
                return;
            }

            $params[] = $userId;

            db()->prepare("
                UPDATE users
                SET " . implode(', ', $updates) . "
                WHERE id = ?
            ")->execute($params);
        } catch (Throwable $e) {
        }
    }

    private static function stageNotificationEnabled(array $alert, string $stage): bool
    {
        if ($stage === 'ZONE_APPROACHED') {
            return (int)($alert['notify_stage_1'] ?? 1) === 1;
        }

        if ($stage === 'LIQUIDITY_SWEPT') {
            return (int)($alert['notify_stage_2'] ?? 1) === 1;
        }

        if ($stage === 'STRUCTURE_CONFIRMED' || $stage === 'READY_TO_EXECUTE') {
            return (int)($alert['notify_stage_3'] ?? 1) === 1;
        }

        return true;
    }

    private static function newsWarningForAlert(array $alert): array
    {
        $empty = [
            'has_warning' => false,
            'risk' => 'LOW',
            'title' => '',
            'time' => '',
            'countdown' => '',
            'message' => ''
        ];

        /*
         * News tracking is strictly forward-looking.
         * Do not attach old/released news to live setup alerts.
         */
        if (empty($alert['avoid_news_trades'])) {
            return $empty;
        }

        try {
            if (!self::tableExists('news_events')) {
                return $empty;
            }

            $minutesBefore = (int)($alert['news_block_minutes_before'] ?? 60);
            $minutesBefore = max(15, min(720, $minutesBefore));

            $now = time();
            $future = $now + ($minutesBefore * 60);

            $stmt = db()->prepare("
                SELECT *
                FROM news_events
                WHERE
                    (
                        (event_timestamp IS NOT NULL AND event_timestamp BETWEEN :now_ts AND :future_ts)
                        OR
                        (event_time IS NOT NULL AND event_time BETWEEN FROM_UNIXTIME(:now_dt) AND FROM_UNIXTIME(:future_dt))
                    )
                ORDER BY COALESCE(event_timestamp, UNIX_TIMESTAMP(event_time)) ASC
                LIMIT 120
            ");

            $stmt->execute([
                ':now_ts' => $now,
                ':future_ts' => $future,
                ':now_dt' => $now,
                ':future_dt' => $future,
            ]);

            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];

            $best = null;

            foreach ($rows as $row) {
                $eventTs = self::alertNewsTimestamp($row);

                if ($eventTs <= $now || $eventTs > $future) {
                    continue;
                }

                if (!self::alertNewsAffectsSymbol($row, (string)($alert['symbol'] ?? ''))) {
                    continue;
                }

                $risk = self::alertNewsImpact($row);

                /*
                 * Track meaningful upcoming volatility. Low-impact rows can still
                 * exist in the database, but they should not clutter alert messages.
                 */
                if (!in_array($risk, ['MEDIUM', 'HIGH', 'EXTREME'], true)) {
                    continue;
                }

                $row['__alert_news_ts'] = $eventTs;
                $row['__alert_news_risk'] = $risk;

                if ($best === null) {
                    $best = $row;
                    continue;
                }

                $currentRank = self::alertNewsRiskRank($risk);
                $bestRank = self::alertNewsRiskRank((string)($best['__alert_news_risk'] ?? 'LOW'));

                if ($currentRank > $bestRank || ($currentRank === $bestRank && $eventTs < (int)$best['__alert_news_ts'])) {
                    $best = $row;
                }
            }

            if (!$best) {
                return $empty;
            }

            $title = trim((string)($best['title'] ?? $best['event'] ?? 'Upcoming economic event'));
            $riskLevel = (string)($best['__alert_news_risk'] ?? 'MEDIUM');
            $eventTs = (int)$best['__alert_news_ts'];
            $time = self::formatAlertNewsTime($eventTs);
            $countdown = self::alertNewsCountdown($eventTs - $now);

            return [
                'has_warning' => true,
                'risk' => $riskLevel,
                'title' => $title,
                'time' => $time,
                'countdown' => $countdown,
                'message' => $riskLevel . ' impact upcoming: ' . $title . ' at ' . $time . ' (' . $countdown . ').'
            ];
        } catch (Throwable $e) {
            return $empty;
        }
    }

    private static function tableExists(string $table): bool
    {
        try {
            $stmt = db()->prepare("SHOW TABLES LIKE ?");
            $stmt->execute([$table]);

            return (bool)$stmt->fetchColumn();
        } catch (Throwable $e) {
            return false;
        }
    }

    private static function alertNewsTimestamp(array $event): int
    {
        if (!empty($event['event_timestamp']) && is_numeric($event['event_timestamp'])) {
            return (int)$event['event_timestamp'];
        }

        if (!empty($event['timestamp']) && is_numeric($event['timestamp'])) {
            return (int)$event['timestamp'];
        }

        if (!empty($event['event_time'])) {
            $ts = strtotime((string)$event['event_time']);
            return $ts ?: 0;
        }

        if (!empty($event['time'])) {
            $ts = strtotime((string)$event['time']);
            return $ts ?: 0;
        }

        return 0;
    }

    private static function alertNewsImpact(array $event): string
    {
        $raw = strtoupper(trim(implode(' ', array_filter([
            $event['impact_level'] ?? '',
            $event['impact'] ?? '',
            $event['impact_label'] ?? '',
            $event['impact_raw'] ?? '',
            $event['folder_class'] ?? '',
        ]))));

        if (self::containsText($raw, 'EXTREME')) {
            return 'EXTREME';
        }

        if (self::containsText($raw, 'HIGH') || self::containsText($raw, 'RED')) {
            return 'HIGH';
        }

        if (self::containsText($raw, 'MEDIUM') || self::containsText($raw, 'ORANGE') || self::containsText($raw, 'SEMI-RED') || self::containsText($raw, 'SEMI RED')) {
            return 'MEDIUM';
        }

        return 'LOW';
    }

    private static function alertNewsRiskRank(string $risk): int
    {
        $risk = strtoupper($risk);
        if ($risk === 'EXTREME') { return 4; }
        if ($risk === 'HIGH') { return 3; }
        if ($risk === 'MEDIUM') { return 2; }
        return 1;
    }


    private static function alertNewsAffectsSymbol(array $event, string $symbol): bool
    {
        $symbol = strtoupper(str_replace('/', '', trim($symbol)));
        $currency = strtoupper(trim((string)($event['currency'] ?? $event['country'] ?? '')));
        $title = strtoupper((string)($event['title'] ?? $event['event'] ?? ''));

        if ($symbol === '') {
            return true;
        }

        if ($symbol === 'XAUUSD') {
            if (in_array($currency, ['USD', 'US', 'USA'], true)) {
                return true;
            }

            foreach (['USD', 'UNITED STATES', 'U.S.', 'US ', 'FED', 'FOMC', 'POWELL', 'TREASURY'] as $word) {
                if (self::containsText($title, $word)) {
                    return true;
                }
            }

            return false;
        }

        $base = substr($symbol, 0, 3);
        $quote = substr($symbol, 3, 3);

        return (
            $currency === $base
            || $currency === $quote
            || self::containsText($title, $base)
            || self::containsText($title, $quote)
        );
    }

    private static function formatAlertNewsTime(int $timestamp): string
    {
        if ($timestamp <= 0) {
            return 'N/A';
        }

        return date('M j, Y g:i A', $timestamp);
    }

    private static function alertNewsCountdown(int $seconds): string
    {
        if ($seconds <= 0) {
            return 'now';
        }

        $minutes = (int)ceil($seconds / 60);

        if ($minutes < 60) {
            return 'in ' . $minutes . 'm';
        }

        $hours = intdiv($minutes, 60);
        $remaining = $minutes % 60;

        return 'in ' . $hours . 'h' . ($remaining ? ' ' . $remaining . 'm' : '');
    }

    private static function appendNewsWarningToTelegram(string $message, array $warning): string
    {
        if (empty($warning['has_warning'])) {
            return $message;
        }

        return $message
            . "\n\n<b>Upcoming News Window</b>"
            . "\n<b>Risk:</b> " . htmlspecialchars((string)($warning['risk'] ?? 'N/A'), ENT_QUOTES, 'UTF-8')
            . "\n<b>Event:</b> " . htmlspecialchars((string)($warning['title'] ?? 'N/A'), ENT_QUOTES, 'UTF-8')
            . "\n<b>Time:</b> " . htmlspecialchars((string)($warning['time'] ?? 'N/A'), ENT_QUOTES, 'UTF-8')
            . "\n<b>Starts:</b> " . htmlspecialchars((string)($warning['countdown'] ?? 'N/A'), ENT_QUOTES, 'UTF-8')
            . "\n\nManage risk carefully around the release.";
    }

    private static function appendNewsWarningToEmail(string $body, array $warning): string
    {
        if (empty($warning['has_warning'])) {
            return $body;
        }

        $html = '<div style="margin-top:22px;padding:16px;background:#fff7ed;border:1px solid #fed7aa;border-left:4px solid #f97316;border-radius:12px;color:#7c2d12;font-size:14px;line-height:1.7;">'
            . '<div style="font-weight:800;font-size:15px;margin-bottom:8px;">Upcoming News Window</div>'
            . '<div><strong>Risk:</strong> ' . htmlspecialchars((string)($warning['risk'] ?? 'N/A'), ENT_QUOTES, 'UTF-8') . '</div>'
            . '<div><strong>Event:</strong> ' . htmlspecialchars((string)($warning['title'] ?? 'N/A'), ENT_QUOTES, 'UTF-8') . '</div>'
            . '<div><strong>Time:</strong> ' . htmlspecialchars((string)($warning['time'] ?? 'N/A'), ENT_QUOTES, 'UTF-8') . '</div>'
            . '<div><strong>Starts:</strong> ' . htmlspecialchars((string)($warning['countdown'] ?? 'N/A'), ENT_QUOTES, 'UTF-8') . '</div>'
            . '<div style="margin-top:8px;">Manage risk carefully around the release.</div>'
            . '</div>';

        if (stripos($body, '</body>') !== false) {
            return str_ireplace('</body>', $html . '</body>', $body);
        }

        return $body . $html;
    }

    private static function alertSymbolConfig(string $symbol): array
    {
        try {
            if (class_exists('SystemConfigService') && method_exists('SystemConfigService', 'getSymbolConfig')) {
                $config = SystemConfigService::getSymbolConfig($symbol);
                return is_array($config) ? $config : [];
            }
        } catch (Throwable $e) {
        }

        return [];
    }


    private static function alertAssetType(string $symbol): string
    {
        $config = self::alertSymbolConfig($symbol);
        $assetType = strtoupper((string)($config['asset_type'] ?? ''));

        if ($assetType !== '') {
            if (in_array($assetType, ['CRYPTOCURRENCY'], true)) {
                return 'CRYPTO';
            }

            if (in_array($assetType, ['INDEXES', 'INDEX'], true)) {
                return 'INDICES';
            }

            if (in_array($assetType, ['METALS'], true)) {
                return 'METAL';
            }

            return $assetType;
        }

        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim($symbol)));

        if (preg_match('/^(BTC|ETH|BNB|SOL|XRP|ADA|DOGE|LTC|BCH|AVAX|DOT|MATIC|TRX|LINK|ATOM|NEAR|APT|OP|ARB)/', $symbol)) {
            return 'CRYPTO';
        }

        if (str_contains($symbol, 'XAU') || str_contains($symbol, 'XAG')) {
            return 'METAL';
        }

        if (preg_match('/^[A-Z]{6}$/', $symbol)) {
            return 'FOREX';
        }

        return 'OTHER';
    }

    private static function alertPipSize(string $symbol): float
    {
        $config = self::alertSymbolConfig($symbol);
        $pipSize = (float)($config['pip_size'] ?? 0);

        if ($pipSize > 0) {
            return $pipSize;
        }

        if (class_exists('RiskService') && method_exists('RiskService', 'pipSize')) {
            return (float)RiskService::pipSize($symbol);
        }

        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim($symbol)));

        if ($symbol === 'XAUUSD') {
            return 0.1;
        }

        if ($symbol === 'XAGUSD') {
            return 0.01;
        }

        if (substr($symbol, -3) === 'JPY') {
            return 0.01;
        }

        if (self::alertAssetType($symbol) === 'CRYPTO') {
            return 1.0;
        }

        return 0.0001;
    }

    private static function alertMinLot(string $symbol): string
    {
        $config = self::alertSymbolConfig($symbol);
        $value = (float)($config['min_lot'] ?? 0);

        if ($value <= 0 && class_exists('RiskService') && method_exists('RiskService', 'minLotForSymbol')) {
            $value = (float)RiskService::minLotForSymbol($symbol);
        }

        return self::alertFormatLot($value > 0 ? $value : 0.01, self::alertLotStepValue($symbol));
    }

    private static function alertLotStep(string $symbol): string
    {
        return self::alertFormatLot(self::alertLotStepValue($symbol), self::alertLotStepValue($symbol));
    }

    private static function alertLotStepValue(string $symbol): float
    {
        $config = self::alertSymbolConfig($symbol);
        $value = (float)($config['lot_step'] ?? 0);

        if ($value > 0) {
            return $value;
        }

        if (class_exists('RiskService') && method_exists('RiskService', 'lotStepForSymbol')) {
            return (float)RiskService::lotStepForSymbol($symbol);
        }

        return self::alertAssetType($symbol) === 'CRYPTO' ? 0.001 : 0.01;
    }

    private static function alertFormatLot(float $value, float $step = 0.01): string
    {
        if ($value <= 0) {
            return '0';
        }

        if (class_exists('RiskService') && method_exists('RiskService', 'formatLot')) {
            return RiskService::formatLot($value, $step);
        }

        $stepText = rtrim(rtrim(sprintf('%.8F', $step), '0'), '.');
        $pos = strpos($stepText, '.');
        $precision = $pos === false ? 0 : min(8, strlen($stepText) - $pos - 1);

        return number_format($value, $precision, '.', '');
    }

    private static function alertDistanceLabel($distance, string $symbol): string
    {
        if ($distance === null || $distance === '' || !is_numeric($distance)) {
            return 'N/A';
        }

        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim($symbol)));
        $distance = abs((float)$distance);
        $pipSize = self::alertPipSize($symbol);
        $pips = $pipSize > 0 ? ($distance / $pipSize) : 0;
        $decimals = self::alertPricePrecision($symbol);
        $assetType = self::alertAssetType($symbol);
        $unit = ($assetType === 'CRYPTO' || $assetType === 'INDICES' || $assetType === 'INDEX') ? 'points' : 'pips';

        return number_format($distance, $decimals, '.', '') . ' (' . number_format($pips, 0) . ' ' . $unit . ')';
    }

    private static function alertPricePrecision(string $symbol): int
    {
        $config = self::alertSymbolConfig($symbol);
        $decimals = (int)($config['decimals'] ?? 0);

        if ($decimals > 0) {
            return $decimals;
        }

        $symbol = strtoupper(str_replace(['/', '-', '_', ' '], '', trim($symbol)));
        $assetType = self::alertAssetType($symbol);

        if ($symbol === 'XAUUSD') {
            return 2;
        }

        if ($symbol === 'XAGUSD') {
            return 3;
        }

        if ($assetType === 'CRYPTO') {
            return 2;
        }

        if ($assetType === 'INDICES' || $assetType === 'INDEX') {
            return 2;
        }

        if (substr($symbol, -3) === 'JPY') {
            return 3;
        }

        if (preg_match('/^[A-Z]{6}$/', $symbol)) {
            return 5;
        }

        return 2;
    }


    private static function alertFormatPrice($value, string $symbol = '', string $fallback = 'Waiting'): string
    {
        if ($value === null || $value === '' || strtoupper((string)$value) === 'NULL') {
            return $fallback;
        }

        if (!is_numeric($value)) {
            return (string)$value;
        }

        return number_format((float)$value, self::alertPricePrecision($symbol), '.', '');
    }


    private static function alertDisplayValue($value, string $fallback = 'Waiting'): string
    {
        if ($value === null || $value === '' || strtoupper((string)$value) === 'NULL') {
            return $fallback;
        }

        if (is_numeric($value)) {
            return rtrim(rtrim(number_format((float)$value, 2, '.', ''), '0'), '.');
        }

        return (string)$value;
    }

    private static function alertEntryZone(array $alert): string
    {
        $symbol = (string)($alert['symbol'] ?? '');

        $low = self::alertFormatPrice($alert['entry_low'] ?? null, $symbol, '');
        $high = self::alertFormatPrice($alert['entry_high'] ?? null, $symbol, '');

        if ($low !== '' && $high !== '') {
            return $low . ' - ' . $high;
        }

        return 'Waiting for valid zone';
    }


    private static function alertTargetsSummary(array $alert): string
    {
        $symbol = (string)($alert['symbol'] ?? '');
        $targets = [];

        foreach (['tp1' => 'TP1', 'tp2' => 'TP2', 'tp3' => 'TP3'] as $key => $label) {
            $value = self::alertFormatPrice($alert[$key] ?? null, $symbol, '');
            if ($value !== '') {
                $targets[] = $label . ': ' . $value;
            }
        }

        return $targets ? implode(' | ', $targets) : 'Waiting';
    }


    private static function alertRiskDistanceLabel(array $alert): string
    {
        $symbol = (string)($alert['symbol'] ?? '');
        $entry = self::num($alert['entry_price'] ?? null);
        $stop = self::num($alert['stop_loss'] ?? null);

        if ($entry === null || $stop === null) {
            return 'Waiting';
        }

        return self::alertDistanceLabel(abs($entry - $stop), $symbol);
    }



    private static function finalExecutionChecklist(array $alert, array $market, array $evaluation, ?array $execution = null, ?array $newsWarning = null): array
    {
        $stage = (string)($evaluation['lifecycle_stage'] ?? '');
        $stage3Sent = !empty($alert['stage3_sent_at']) || !empty($alert['stage3_sent_ts']) || $stage === 'STRUCTURE_CONFIRMED' || $stage === 'READY_TO_EXECUTE';

        $newsWarning = $newsWarning ?? self::newsWarningForAlert($alert);
        $displacement = self::freshDisplacementCheck($alert, $market);

        $locationPassed = !empty($evaluation['zone_reached']) || !empty($market['zone_touched']) || !empty($alert['stage1_sent_at']);
        $liquidityPassed = !empty($evaluation['liquidity_swept']) || !empty($market['liquidity_swept']) || !empty($alert['stage2_sent_at']);
        $bosPassed = !empty($evaluation['structure_confirmed']) || !empty($market['bos_confirmed']) || !empty($market['choch_confirmed']) || $stage3Sent;
        $candleClosePassed = !empty($evaluation['candle_close_confirmed']) || !empty($evaluation['candle_close_passed']);
        $marketGuard = self::finalMarketReadinessGuard($alert, $market);
        $marketOpenPassed = !empty($marketGuard['passed']);
        $sessionPassed = $marketOpenPassed && (array_key_exists('session_passed', $evaluation)
            ? !empty($evaluation['session_passed'])
            : self::sessionFilterPassed(self::currentTradingSession()));
        $newsPassed = self::newsPreferenceAllowsFinal($alert, $newsWarning);
        $riskReward = self::riskRewardCheck($alert, $execution);
        $validity = self::setupValidityCheck($alert, $market, $evaluation);

        /*
         * Production checklist behaviour:
         * A setup should not be marked FAILED simply because the next future
         * stage has not happened yet. Before final execution, missing sequence
         * gates are PENDING. FAILED is reserved for true blockers/invalidation.
         */
        $items = [
            'location' => [
                'label' => 'Location',
                'status' => $locationPassed ? 'passed' : 'pending',
                'reason' => $locationPassed
                    ? trim((string)($market['zone_evidence'] ?? 'Tracked institutional zone reached.'))
                    : 'Waiting for price to approach the tracked institutional zone from the correct side.',
            ],
            'liquidity_sweep' => [
                'label' => 'Liquidity sweep',
                'status' => $liquidityPassed ? 'passed' : 'pending',
                'reason' => $liquidityPassed
                    ? trim((string)($market['sweep_evidence'] ?? 'Liquidity sweep confirmed.'))
                    : ($locationPassed ? 'Waiting for fresh liquidity sweep after Stage 1.' : 'Waiting for Stage 1 first.'),
            ],
            'fresh_displacement' => [
                'label' => 'Fresh displacement candle',
                'status' => !empty($displacement['passed']) ? 'passed' : 'pending',
                'reason' => !empty($displacement['passed'])
                    ? ($displacement['reason'] ?? 'Fresh displacement candle confirmed.')
                    : ($liquidityPassed ? 'Waiting for a strong displacement candle after the sweep.' : 'Waiting for liquidity sweep first.'),
            ],
            'bos_choch' => [
                'label' => 'BOS/CHoCH',
                'status' => $bosPassed ? 'passed' : 'pending',
                'reason' => $bosPassed
                    ? trim((string)($market['structure_evidence'] ?? 'BOS/CHoCH structure confirmation detected.'))
                    : ($liquidityPassed ? 'Waiting for fresh BOS/CHoCH after the liquidity sweep.' : 'Waiting for liquidity sweep first.'),
            ],
            'candle_close' => [
                'label' => 'Candle close confirmation',
                'status' => $candleClosePassed ? 'passed' : 'pending',
                'reason' => $candleClosePassed
                    ? trim((string)($evaluation['candle_close_reason'] ?? 'Closed-candle confirmation passed.'))
                    : ($bosPassed ? trim((string)($evaluation['candle_close_reason'] ?? 'Waiting for the confirming candle close.')) : 'Waiting for BOS/CHoCH first.'),
            ],
            'market_open' => [
                'label' => 'Market open',
                'status' => $marketOpenPassed ? 'passed' : 'pending',
                'reason' => $marketOpenPassed
                    ? ($marketGuard['reason'] ?? 'Market is open and final confirmation data is fresh.')
                    : ($marketGuard['reason'] ?? 'Waiting for market reopen and fresh final confirmation.'),
            ],
            'session' => [
                'label' => 'Session',
                'status' => $sessionPassed ? 'passed' : 'pending',
                'reason' => $sessionPassed
                    ? 'Trading session passed: ' . (string)($evaluation['session_name'] ?? self::currentTradingSession()) . '.'
                    : 'Waiting for a valid open-market trading session.',
            ],
            'news_risk' => [
                'label' => 'News risk',
                'status' => $newsPassed ? 'passed' : 'failed',
                'reason' => $newsPassed
                    ? trim((string)($newsWarning['message'] ?? 'No blocking news risk for this setup.'))
                    : self::finalPreferenceBlockReason($alert, $newsWarning),
            ],
            'risk_reward' => [
                'label' => 'Risk/reward advisory',
                'status' => ($riskReward['status'] ?? 'pending') === 'failed' && !$stage3Sent ? 'pending' : ($riskReward['status'] ?? 'pending'),
                'reason' => ($riskReward['status'] ?? 'pending') === 'failed' && !$stage3Sent
                    ? 'Risk/reward will be calculated after final execution conditions become available. RR is advisory and will not block a structurally valid setup.'
                    : ($riskReward['reason'] ?? 'Waiting for final execution calculation.'),
                'blocking' => !empty($riskReward['blocking']),
            ],
            'setup_validity' => [
                'label' => 'Setup validity',
                'status' => ($validity['status'] ?? 'pending') === 'failed' && !$stage3Sent ? 'pending' : ($validity['status'] ?? 'pending'),
                'reason' => ($validity['status'] ?? 'pending') === 'failed' && !$stage3Sent
                    ? 'Setup is still tracking; final validity is confirmed after structure confirmation.'
                    : ($validity['reason'] ?? 'Waiting for setup validation.'),
            ],
        ];

        $failed = [];
        $pending = [];
        $advisory = [];

        foreach ($items as $key => $item) {
            $status = strtolower((string)($item['status'] ?? 'pending'));
            $blocking = array_key_exists('blocking', $item) ? (bool)$item['blocking'] : true;
            $label = (string)($item['label'] ?? $key);

            if ($status === 'failed') {
                if ($blocking) {
                    $failed[] = $label;
                } else {
                    $advisory[] = $label;
                }
            } elseif ($status === 'pending') {
                if ($blocking) {
                    $pending[] = $label;
                } else {
                    $advisory[] = $label;
                }
            } elseif (in_array($status, ['advisory', 'warning'], true)) {
                $advisory[] = $label;
            }
        }

        $passed = empty($failed) && empty($pending);

        return [
            'passed' => $passed,
            'status' => $passed ? 'PASSED' : (!empty($failed) ? 'FAILED' : 'PENDING'),
            'failed' => $failed,
            'pending' => $pending,
            'advisory' => array_values(array_unique($advisory)),
            'items' => $items,
            'generated_at' => date('Y-m-d H:i:s'),
        ];
    }

    private static function riskRewardUserAdvisory(array $checklist): string
    {
        $items = is_array($checklist['items'] ?? null) ? $checklist['items'] : [];
        $rr = $items['risk_reward'] ?? null;

        if (!is_array($rr)) {
            return '';
        }

        $reason = trim(strip_tags((string)($rr['reason'] ?? '')));
        if ($reason === '') {
            return '';
        }

        $lower = strtolower($reason);
        if (strpos($lower, 'will be calculated') !== false || strpos($lower, 'waiting for final') !== false) {
            return '';
        }

        if (strpos($lower, 'price data is missing') !== false || strpos($lower, 'directionally valid') !== false) {
            return '';
        }

        $status = strtolower((string)($rr['status'] ?? ''));
        if ($status === 'passed') {
            return 'Risk/reward is acceptable for the current execution model.';
        }

        return 'Risk/reward advisory: ' . $reason;
    }

    private static function checklistItemLine(array $item, string $format = 'plain'): string
    {
        $status = strtolower((string)($item['status'] ?? 'pending'));
        $label = (string)($item['label'] ?? 'Checklist item');
        $reason = trim((string)($item['reason'] ?? ''));

        if ($format === 'telegram') {
            $icon = $status === 'passed' ? '✅' : ($status === 'failed' ? '❌' : (in_array($status, ['advisory', 'warning'], true) ? '⚠️' : '⏳'));
            $word = strtoupper($status);
            return $icon . ' <b>' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ':</b> ' . $word . ($reason !== '' ? ' - ' . htmlspecialchars($reason, ENT_QUOTES, 'UTF-8') : '');
        }

        $word = strtoupper($status);
        return $label . ': ' . $word . ($reason !== '' ? ' - ' . $reason : '');
    }

    private static function executionChecklistPlain(array $checklist): string
    {
        $lines = ['Final Execution Checklist:'];

        foreach (($checklist['items'] ?? []) as $item) {
            if (is_array($item)) {
                $lines[] = self::checklistItemLine($item, 'plain');
            }
        }

        return implode("\n", $lines);
    }

    private static function executionChecklistTelegram(array $checklist): string
    {
        if (empty($checklist['items']) || !is_array($checklist['items'])) {
            return '';
        }

        $lines = ["<b>Final Execution Checklist</b>"];

        foreach ($checklist['items'] as $item) {
            if (is_array($item)) {
                $lines[] = self::checklistItemLine($item, 'telegram');
            }
        }

        return implode("\n", $lines);
    }

    private static function executionChecklistHtml(array $checklist): string
    {
        if (empty($checklist['items']) || !is_array($checklist['items'])) {
            return '';
        }

        $html = '<div style="margin-top:20px;padding:16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;">'
            . '<div style="font-weight:800;font-size:15px;margin-bottom:10px;color:#0f172a;">Final Execution Checklist</div>'
            . '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">';

        foreach ($checklist['items'] as $item) {
            if (!is_array($item)) {
                continue;
            }

            $status = strtolower((string)($item['status'] ?? 'pending'));
            $label = htmlspecialchars((string)($item['label'] ?? ''), ENT_QUOTES, 'UTF-8');
            $reason = htmlspecialchars((string)($item['reason'] ?? ''), ENT_QUOTES, 'UTF-8');
            $color = $status === 'passed' ? '#16a34a' : ($status === 'failed' ? '#dc2626' : (in_array($status, ['advisory', 'warning'], true) ? '#d97706' : '#64748b'));
            $word = strtoupper($status);

            $html .= '<tr>'
                . '<td style="padding:8px 0;border-top:1px solid #e2e8f0;color:#334155;font-weight:700;width:210px;">' . $label . '</td>'
                . '<td style="padding:8px 0;border-top:1px solid #e2e8f0;color:' . $color . ';font-weight:800;width:85px;">' . $word . '</td>'
                . '<td style="padding:8px 0;border-top:1px solid #e2e8f0;color:#64748b;font-size:13px;line-height:1.45;">' . $reason . '</td>'
                . '</tr>';
        }

        return $html . '</table></div>';
    }

    private static function saveFinalExecutionChecklist(int $alertId, array $checklist): void
    {
        if ($alertId <= 0) {
            return;
        }

        try {
            $status = (string)($checklist['status'] ?? 'UNKNOWN');
            $reason = self::checklistFailureSummary($checklist);

            db()->prepare("
                UPDATE institutional_alerts
                SET last_execution_checklist = ?,
                    last_execution_checklist_status = ?,
                    last_execution_checklist_reason = ?,
                    last_execution_checklist_at = NOW(),
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([
                json_encode($checklist, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
                $status,
                $reason,
                $alertId
            ]);
        } catch (Throwable $e) {
        }
    }

    private static function checklistFailureSummary(array $checklist): string
    {
        $failed = $checklist['failed'] ?? [];
        $pending = $checklist['pending'] ?? [];
        $advisory = $checklist['advisory'] ?? [];

        if (!empty($failed)) {
            return 'Failed: ' . implode(', ', $failed);
        }

        if (!empty($pending)) {
            return 'Pending: ' . implode(', ', $pending);
        }

        if (!empty($advisory)) {
            return 'All blocking checklist conditions passed. Advisory: ' . implode(', ', $advisory) . '.';
        }

        return 'All checklist conditions passed.';
    }

    private static function freshDisplacementCheck(array $alert, array $market): array
    {
        $side = strtoupper((string)($alert['side'] ?? ''));
        $candles = is_array($market['candles'] ?? null) ? $market['candles'] : [];
        $structureTs = (int)($market['structure_event_ts'] ?? 0);
        $stage2Ts = (int)($market['sweep_event_ts'] ?? ($market['stage2_reference_ts'] ?? 0));

        if (!in_array($side, ['BUY', 'SELL'], true)) {
            return ['passed' => false, 'reason' => 'Invalid setup direction for displacement check.'];
        }

        if (empty($candles)) {
            return ['passed' => false, 'reason' => 'No candles available for displacement validation.'];
        }

        $selected = null;
        $selectedIndex = null;

        foreach ($candles as $i => $candle) {
            $closeTs = (int)($candle['__close_ts'] ?? 0);

            if ($structureTs > 0 && abs($closeTs - $structureTs) <= 2) {
                $selected = $candle;
                $selectedIndex = $i;
                break;
            }
        }

        if ($selected === null) {
            foreach ($candles as $i => $candle) {
                $closeTs = (int)($candle['__close_ts'] ?? 0);
                if ($stage2Ts > 0 && $closeTs > $stage2Ts) {
                    $selected = $candle;
                    $selectedIndex = $i;
                }
            }
        }

        if ($selected === null || $selectedIndex === null) {
            return ['passed' => false, 'reason' => 'No post-sweep candle found for displacement validation.'];
        }

        $open = self::num($selected['open_price'] ?? null);
        $high = self::num($selected['high_price'] ?? null);
        $low = self::num($selected['low_price'] ?? null);
        $close = self::num($selected['close_price'] ?? null);

        if ($open === null || $high === null || $low === null || $close === null || $high <= $low) {
            return ['passed' => false, 'reason' => 'Displacement candle has incomplete OHLC data.'];
        }

        $body = abs($close - $open);
        $range = max(0.00000001, $high - $low);
        $bodyRatio = $body / $range;
        $bodyRatioRequired = (float)(self::getSetting('displacement_min_body_ratio') ?: 0.55);
        $bodyRatioRequired = max(0.35, min(0.90, $bodyRatioRequired));

        $directionPassed = $side === 'BUY'
            ? ($close > $open && $close >= ($low + ($range * 0.65)))
            : ($close < $open && $close <= ($high - ($range * 0.65)));

        $window = array_slice($candles, max(0, $selectedIndex - 5), 5);
        $structurePassed = true;

        if (count($window) >= 3) {
            $prevHigh = max(array_map(function ($c) {
                return (float)($c['high_price'] ?? 0);
            }, $window));

            $prevLow = min(array_map(function ($c) {
                return (float)($c['low_price'] ?? PHP_FLOAT_MAX);
            }, $window));

            $structurePassed = $side === 'BUY'
                ? ($close > $prevHigh)
                : ($close < $prevLow);
        }

        $passed = $bodyRatio >= $bodyRatioRequired && $directionPassed && $structurePassed;

        return [
            'passed' => $passed,
            'reason' => $passed
                ? 'Fresh displacement candle passed: body ratio ' . number_format($bodyRatio * 100, 1) . '% on candle ' . ($selected['open_time'] ?? 'N/A') . '.'
                : 'Displacement not strong enough yet: body ratio ' . number_format($bodyRatio * 100, 1) . '%, directional close=' . ($directionPassed ? 'yes' : 'no') . ', structure break=' . ($structurePassed ? 'yes' : 'no') . '.',
            'body_ratio' => $bodyRatio,
            'candle_time' => $selected['open_time'] ?? null,
        ];
    }

    private static function riskRewardCheck(array $alert, ?array $execution = null): array
    {
        if ($execution === null) {
            return [
                'status' => 'pending',
                'blocking' => false,
                'reason' => 'Risk/reward is advisory and will be calculated only at final execution after dynamic entry, SL and targets are available.'
            ];
        }

        $side = strtoupper((string)($execution['side'] ?? ($alert['side'] ?? '')));
        $entry = self::num($execution['entry_price'] ?? ($alert['entry_price'] ?? null));
        $sl = self::num($execution['stop_loss'] ?? ($alert['stop_loss'] ?? null));
        $tp1 = self::num($execution['tp1'] ?? ($alert['tp1'] ?? null));
        $minRr = self::num($execution['min_rr'] ?? null);
        $minRr = $minRr !== null && $minRr > 0 ? $minRr : 1.0;

        if (!in_array($side, ['BUY', 'SELL'], true) || $entry === null || $sl === null || $tp1 === null) {
            return [
                'status' => 'failed',
                'blocking' => true,
                'reason' => 'Final execution price data is missing. Entry, stop loss and TP1 are required before a safe execution alert can be sent.'
            ];
        }

        $risk = abs($entry - $sl);
        $reward = abs($tp1 - $entry);
        $rr = $risk > 0 ? ($reward / $risk) : 0;

        $directionValid = $side === 'BUY'
            ? ($sl < $entry && $tp1 > $entry)
            : ($sl > $entry && $tp1 < $entry);

        if (!$directionValid || $risk <= 0 || $reward <= 0) {
            return [
                'status' => 'failed',
                'blocking' => true,
                'reason' => 'Execution prices are not directionally valid for ' . $side . '. This is a price-integrity blocker, not an RR filter.'
            ];
        }

        if ($rr + 0.0001 < $minRr) {
            return [
                'status' => 'advisory',
                'blocking' => false,
                'reason' => 'RR advisory only. TP1 RR=' . number_format($rr, 2) . ', preferred minimum=' . number_format($minRr, 2) . '. The setup is not blocked; consider waiting for a better entry, reducing risk, or managing at TP2/TP3.'
            ];
        }

        return [
            'status' => 'passed',
            'blocking' => false,
            'reason' => 'Risk/reward acceptable. TP1 RR=' . number_format($rr, 2) . ', preferred minimum=' . number_format($minRr, 2) . '.'
        ];
    }

    private static function setupValidityCheck(array $alert, array $market, array $evaluation): array
    {
        if ((int)($alert['is_active'] ?? 1) !== 1) {
            return ['status' => 'failed', 'reason' => 'Alert is no longer active.'];
        }

        if (!empty($alert['triggered_at']) || !empty($alert['expired_at'])) {
            return ['status' => 'failed', 'reason' => 'Setup is already triggered or expired.'];
        }

        if (self::alertExpired($alert)) {
            return ['status' => 'failed', 'reason' => 'Setup expired based on configured timeframe rules.'];
        }

        if (empty($alert['entry_low']) || empty($alert['entry_high'])) {
            return ['status' => 'failed', 'reason' => 'Tracked zone is missing.'];
        }

        if (empty($evaluation['structure_confirmed']) && empty($market['bos_confirmed']) && empty($market['choch_confirmed'])) {
            return ['status' => 'pending', 'reason' => 'Setup is valid but still waiting for structure confirmation.'];
        }

        return ['status' => 'passed', 'reason' => 'Setup is active, not expired, has a tracked zone, and remains valid for final execution.'];
    }


    private static function stageHasLiveEvidence(string $stage, array $market): bool
    {
        if (self::settingEnabled('market_open_guard_enabled', true) && array_key_exists('market_open', $market) && empty($market['market_open'])) {
            return false;
        }

        $stage = strtoupper($stage);
        $zoneEvidence = trim((string)($market['zone_evidence'] ?? ''));
        $sweepEvidence = trim((string)($market['sweep_evidence'] ?? ''));
        $structureEvidence = trim((string)($market['structure_evidence'] ?? ''));

        if ($stage === 'ZONE_APPROACHED') {
            return !empty($market['zone_touched']) && $zoneEvidence !== '';
        }

        if ($stage === 'LIQUIDITY_SWEPT') {
            return !empty($market['liquidity_swept']) && $sweepEvidence !== '';
        }

        if ($stage === 'STRUCTURE_CONFIRMED') {
            return (!empty($market['bos_confirmed']) || !empty($market['choch_confirmed'])) && $structureEvidence !== '';
        }

        return true;
    }

    private static function stagePlainMessage(string $stage): string
    {
        switch ($stage) {
            case 'ZONE_APPROACHED': return 'Fresh post-baseline touch/entry into the tracked institutional zone. The candle does not have to close inside the zone.';
            case 'LIQUIDITY_SWEPT': return 'Fresh liquidity sweep after the Stage 1 zone-touch event. Final confirmation still waits for closed-candle structure.';
            case 'STRUCTURE_CONFIRMED': return 'Fresh BOS/CHoCH closed-candle confirmation after the Stage 2 sweep.';
            case 'READY_TO_EXECUTE': return 'All final confirmation conditions have aligned.';
            default: return 'Tracking active.';
        }
    }




    private static function stageLabel(string $stage): string
    {
        switch ($stage) {
            case 'ZONE_APPROACHED': return 'Stage 1: Zone Approached';
            case 'LIQUIDITY_SWEPT': return 'Stage 2: Liquidity Swept';
            case 'STRUCTURE_CONFIRMED': return 'Stage 3: Structure Confirmed';
            case 'READY_TO_EXECUTE': return 'Final: Execution Ready';
            default: return 'Setup Tracking';
        }
    }


    private static function stageNextStep(string $stage): string
    {
        switch ($stage) {
            case 'ZONE_APPROACHED': return 'Wait for a fresh liquidity sweep.';
            case 'LIQUIDITY_SWEPT': return 'Wait for displacement / BOS / CHoCH confirmation.';
            case 'STRUCTURE_CONFIRMED': return 'Wait for final candle confirmation and quality filters.';
            case 'READY_TO_EXECUTE': return 'Review risk and execute only if it still matches your plan.';
            default: return 'Wait for price to reach the tracked zone.';
        }
    }


    private static function notificationConfirmationTimeframe(array $alert, array $market): string
    {
        /*
        |--------------------------------------------------------------------------
        | Notification timeframe display
        |--------------------------------------------------------------------------
        | Dashboard/my_alerts shows the mapped confirmation timeframe for the saved
        | setup timeframe. Email/Telegram must show that same lower confirmation TF.
        | Do not use market['scan_timeframe'] for user-facing notifications, because
        | scan_timeframe can temporarily fall back to the setup TF when LTF candles
        | are stale/unavailable. That fallback is only an engine/runtime detail.
        */

        $setupRaw = (string)($market['setup_timeframe'] ?? ($alert['timeframe'] ?? ''));
        $setupTimeframe = self::normalizeAlertTimeframe($setupRaw);

        if ($setupTimeframe === '') {
            return 'N/A';
        }

        $configuredConfirm = trim((string)($market['confirmation_timeframe'] ?? ''));
        if ($configuredConfirm !== '') {
            return self::normalizeAlertTimeframe($configuredConfirm);
        }

        if (self::lowerTimeframeConfirmationEnabled()) {
            return self::normalizeAlertTimeframe(self::confirmationTimeframeForSetup($setupTimeframe));
        }

        return $setupTimeframe;
    }

    private static function templateData(array $alert, array $market, array $evaluation): array
    {
        $stage = (string)($evaluation['lifecycle_stage'] ?? 'TRACKING');
        $symbol = (string)($alert['symbol'] ?? '');
        $setupTimeframe = self::normalizeAlertTimeframe((string)($market['setup_timeframe'] ?? ($alert['timeframe'] ?? '')));
        $notificationConfirmTimeframe = self::notificationConfirmationTimeframe($alert, $market);
        $actualScanTimeframe = self::normalizeAlertTimeframe((string)($market['scan_timeframe'] ?? $notificationConfirmTimeframe));

        return [
            'name' => self::alertDisplayValue($alert['name'] ?? null, 'Trader'),
            'symbol' => self::alertDisplayValue($symbol, 'N/A'),
            'side' => self::alertDisplayValue($alert['side'] ?? null, 'N/A'),
            'timeframe' => self::alertDisplayValue($setupTimeframe ?: ($alert['timeframe'] ?? null), 'N/A'),
            'setup_timeframe' => self::alertDisplayValue($setupTimeframe ?: ($alert['timeframe'] ?? null), 'N/A'),
            'confirmation_timeframe' => self::alertDisplayValue($notificationConfirmTimeframe, 'N/A'),
            'confirm_timeframe' => self::alertDisplayValue($notificationConfirmTimeframe, 'N/A'),
            'scan_timeframe' => self::alertDisplayValue($notificationConfirmTimeframe, 'N/A'),
            'actual_scan_timeframe' => self::alertDisplayValue($actualScanTimeframe, 'N/A'),
            'style' => self::alertDisplayValue($alert['style'] ?? null, 'N/A'),
            'asset_type' => self::alertAssetType($symbol),
            'pip_size' => self::alertDisplayValue(self::alertPipSize($symbol), 'N/A'),
            'min_lot' => self::alertMinLot($symbol),
            'lot_step' => self::alertLotStep($symbol),

            /*
             * Symbol-aware price formatting:
             * - EURUSD/GBPUSD etc. usually 5 decimals
             * - JPY pairs usually 3 decimals
             * - XAUUSD usually 2 decimals
             * - XAGUSD usually 3 decimals
             * - indices/crypto follow symbol metadata where configured
             */
            'current_price' => self::alertFormatPrice($market['current_price'] ?? null, $symbol, 'N/A'),
            'entry_price' => self::alertFormatPrice($alert['entry_price'] ?? null, $symbol, 'Waiting'),
            'entry_low' => self::alertFormatPrice($alert['entry_low'] ?? null, $symbol, 'Waiting'),
            'entry_high' => self::alertFormatPrice($alert['entry_high'] ?? null, $symbol, 'Waiting'),
            'entry_zone' => self::alertEntryZone($alert),
            'stop_loss' => self::alertFormatPrice($alert['stop_loss'] ?? null, $symbol, 'Waiting'),
            'tp1' => self::alertFormatPrice($alert['tp1'] ?? null, $symbol, 'Waiting'),
            'tp2' => self::alertFormatPrice($alert['tp2'] ?? null, $symbol, 'Waiting'),
            'tp3' => self::alertFormatPrice($alert['tp3'] ?? null, $symbol, 'Waiting'),
            'targets_summary' => self::alertTargetsSummary($alert),
            'risk_distance_label' => self::alertRiskDistanceLabel($alert),

            'lifecycle_stage' => $stage,
            'stage_label' => self::stageLabel($stage),
            'next_step' => self::stageNextStep($stage),
            'message' => $evaluation['message'] ?? '',
            'candle_close_confirmed' => !empty($evaluation['candle_close_confirmed']) ? 'Yes' : 'No',
            'closed_candle_time' => self::alertDisplayValue($evaluation['closed_candle_time'] ?? null, 'N/A'),
            'quality_passed' => !empty($evaluation['quality_passed']) ? 'Yes' : 'No',
            'quality_score' => self::alertDisplayValue($evaluation['quality_score'] ?? null, 'N/A'),
            'quality_reason' => self::alertDisplayValue($evaluation['quality_reason'] ?? null, 'N/A'),
            'session_name' => self::alertDisplayValue($evaluation['session_name'] ?? null, 'N/A'),
            'atr_value' => self::alertDisplayValue($evaluation['atr_value'] ?? null, 'N/A'),
            'volatility_status' => self::alertDisplayValue($evaluation['volatility_status'] ?? null, 'N/A'),
            'cooldown_until' => self::alertDisplayValue($alert['cooldown_until'] ?? null, 'N/A'),
            'duplicate_block_count' => $alert['duplicate_block_count'] ?? 0,
            'mtf_passed' => !empty($evaluation['mtf_passed']) ? 'Yes' : 'No',
            'mtf_status' => self::alertDisplayValue($evaluation['mtf_status'] ?? null, 'N/A'),
            'mtf_reason' => self::alertDisplayValue($evaluation['mtf_reason'] ?? null, 'N/A'),
            'mtf_score' => self::alertDisplayValue($evaluation['mtf_score'] ?? null, 'N/A'),
            'mtf_weight' => self::alertDisplayValue($evaluation['mtf_weight'] ?? null, 'N/A'),
            'mtf_penalty' => self::alertDisplayValue($evaluation['mtf_penalty'] ?? null, 'N/A'),
            'mtf_strictness' => self::alertDisplayValue($evaluation['mtf_strictness'] ?? null, 'N/A'),
            'news_warning' => $evaluation['news_warning'] ?? '',
            'news_risk' => self::alertDisplayValue($evaluation['news_risk'] ?? null, 'LOW'),
            'news_title' => self::alertDisplayValue($evaluation['news_title'] ?? null, 'N/A'),
            'news_time' => self::alertDisplayValue($evaluation['news_time'] ?? null, 'N/A'),
            'news_countdown' => self::alertDisplayValue($evaluation['news_countdown'] ?? null, 'N/A'),
            'execution_checklist_status' => self::alertDisplayValue($evaluation['execution_checklist_status'] ?? null, 'N/A'),
            'execution_checklist_plain' => self::alertDisplayValue($evaluation['execution_checklist_plain'] ?? null, 'N/A'),
            'execution_checklist_telegram' => $evaluation['execution_checklist_telegram'] ?? '',
            'execution_checklist_html' => $evaluation['execution_checklist_html'] ?? ''
        ];
    }


    /**
     * Keep execution checklist data available for dashboard/admin storage, but never
     * expose the full checklist table/list inside customer-facing Email/Telegram alerts.
     */
    private static function notificationTemplateData(array $data): array
    {
        $data['execution_checklist_plain'] = '';
        $data['execution_checklist_telegram'] = '';
        $data['execution_checklist_html'] = '';
        return $data;
    }

    private static function stripExecutionChecklistPlaceholders(string $message): string
    {
        $message = str_replace([
            '{{execution_checklist_plain}}',
            '{{execution_checklist_telegram}}',
            '{{execution_checklist_html}}',
            '{execution_checklist_plain}',
            '{execution_checklist_telegram}',
            '{execution_checklist_html}',
        ], '', $message);

        // Compact the blank space left behind in Telegram/plain text messages.
        if (stripos($message, '<html') === false && stripos($message, '<table') === false) {
            $message = preg_replace("/\n{3,}/", "\n\n", $message) ?? $message;
        }

        return trim($message);
    }


    private static function renderProfessionalAlertTemplate(string $key, array $data, string $channel, string $stage = ''): string
    {
        $template = self::professionalTemplate($key, $channel, $stage);

        if ($template === '') {
            $template = self::getTemplate($key);
        }

        foreach ($data as $k => $v) {
            $template = str_replace('{{' . $k . '}}', (string)$v, $template);
        }

        return self::stripExecutionChecklistPlaceholders($template);
    }

    private static function professionalTemplate(string $key, string $channel, string $stage = ''): string
    {
        if ($channel === 'telegram') {
            if ($key === 'institutional_alert_telegram_body') {
                return "<b>Institutional Setup Ready</b>\n\n"
                    . "<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n"
                    . "<b>Current Price:</b> {{current_price}}\n"
                    . "<b>Entry Zone:</b> {{entry_zone}}\n"
                    . "<b>New Entry:</b> {{entry_price}}\n"
                    . "<b>Stop Loss:</b> {{stop_loss}}\n"
                    . "<b>Targets:</b> {{targets_summary}}\n"
                    . "<b>Risk Distance:</b> {{risk_distance_label}}\n"
                    . "<b>Asset:</b> {{asset_type}} • Min lot {{min_lot}} • Step {{lot_step}}\n\n"
                    . "<b>Quality:</b> {{quality_score}}\n"
                    . "<b>Session:</b> {{session_name}}\n"
                    . "<b>Volatility:</b> {{volatility_status}}\n"
                    . "<b>MTF:</b> {{mtf_status}}\n"
                    . "<b>Candle Close:</b> {{candle_close_status}}\n\n"
                    . "<b>Next Step:</b> {{next_step}}\n"
                    . "Use proper risk management.";
            }

            if ($key === 'stage_zone_approached_telegram') {
                return "<b>Stage 1: Zone Approached</b>\n\n"
                    . "<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n"
                    . "<b>Current Price:</b> {{current_price}}\n"
                    . "<b>Tracked Zone:</b> {{entry_zone}}\n\n"
                    . "<b>Status:</b> Price is inside or approaching the tracked institutional zone.\n"
                    . "<b>Next Step:</b> Wait for a fresh liquidity sweep.";
            }

            if ($key === 'stage_liquidity_swept_telegram') {
                return "<b>Stage 2: Liquidity Swept</b>\n\n"
                    . "<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n"
                    . "<b>Current Price:</b> {{current_price}}\n"
                    . "<b>Tracked Zone:</b> {{entry_zone}}\n\n"
                    . "<b>Status:</b> Liquidity has been swept around the tracked zone.\n"
                    . "<b>Next Step:</b> Wait for displacement / BOS / CHoCH confirmation.";
            }

            if ($key === 'stage_structure_confirmed_telegram') {
                return "<b>Stage 3: Structure Confirmed</b>\n\n"
                    . "<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n"
                    . "<b>Current Price:</b> {{current_price}}\n"
                    . "<b>Tracked Zone:</b> {{entry_zone}}\n\n"
                    . "<b>Status:</b> BOS/CHoCH structure confirmation detected.\n"
                    . "<b>Next Step:</b> Wait for final candle confirmation and quality filters.";
            }
        }

        if ($channel === 'email') {
            if (self::startsWithText($key, 'stage_')) {
                return '<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>{{stage_label}}</title></head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:Arial,sans-serif;color:#0f172a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9;padding:28px 12px;">
<tr><td align="center">
<table role="presentation" width="680" cellpadding="0" cellspacing="0" style="max-width:680px;background:#ffffff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0;">
<tr><td style="background:#0f172a;padding:24px 28px;color:#ffffff;">
<div style="font-size:24px;font-weight:800;letter-spacing:-0.02em;">{{stage_label}}</div>
<div style="margin-top:7px;color:#cbd5e1;font-size:14px;">{{symbol}} • {{side}} • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}</div>
</td></tr>
<tr><td style="padding:28px;">
<p style="margin:0 0 16px;font-size:15px;line-height:1.7;">Hi <strong>{{name}}</strong>,</p>
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;">Your tracked setup has reached the alert stage shown below.</p>

<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
<tr><td style="padding:12px 15px;background:#f8fafc;color:#64748b;font-weight:bold;">Current Price</td><td style="padding:12px 15px;background:#f8fafc;font-weight:bold;">{{current_price}}</td></tr>
<tr><td style="padding:12px 15px;color:#64748b;font-weight:bold;">Tracked Zone</td><td style="padding:12px 15px;">{{entry_zone}}</td></tr>
<tr><td style="padding:12px 15px;background:#f8fafc;color:#64748b;font-weight:bold;">Stage Status</td><td style="padding:12px 15px;background:#f8fafc;">{{stage_label}}</td></tr>
</table>

<div style="margin-top:20px;padding:15px;background:#eff6ff;border-left:4px solid #2563eb;border-radius:10px;font-size:14px;line-height:1.7;">
<strong>Next Step</strong><br>{{next_step}}
</div>

<p style="margin:22px 0 0;color:#64748b;font-size:12px;line-height:1.7;">
Entry price, stop loss and targets will be generated only when the setup becomes execution-ready.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>';
            }

            if ($key === 'institutional_alert_email_body') {
                return '<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>Institutional Setup Ready</title></head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:Arial,sans-serif;color:#0f172a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9;padding:28px 12px;">
<tr><td align="center">
<table role="presentation" width="680" cellpadding="0" cellspacing="0" style="max-width:680px;background:#ffffff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0;">
<tr><td style="background:#0f172a;padding:24px 28px;color:#ffffff;">
<div style="font-size:24px;font-weight:800;letter-spacing:-0.02em;">Institutional Setup Ready</div>
<div style="margin-top:7px;color:#cbd5e1;font-size:14px;">{{symbol}} • {{side}} • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}</div>
</td></tr>
<tr><td style="padding:28px;">
<p style="margin:0 0 16px;font-size:15px;line-height:1.7;">Hi <strong>{{name}}</strong>,</p>
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;">{{message}}</p>

<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
<tr><td style="padding:12px 15px;background:#f8fafc;color:#64748b;font-weight:bold;">Current Price</td><td style="padding:12px 15px;background:#f8fafc;font-weight:bold;">{{current_price}}</td></tr>
<tr><td style="padding:12px 15px;color:#64748b;font-weight:bold;">Entry Zone</td><td style="padding:12px 15px;">{{entry_zone}}</td></tr>
<tr><td style="padding:12px 15px;background:#f8fafc;color:#64748b;font-weight:bold;">New Entry</td><td style="padding:12px 15px;background:#f8fafc;font-weight:bold;">{{entry_price}}</td></tr>
<tr><td style="padding:12px 15px;color:#64748b;font-weight:bold;">Stop Loss</td><td style="padding:12px 15px;color:#dc2626;font-weight:bold;">{{stop_loss}}</td></tr>
<tr><td style="padding:12px 15px;background:#f8fafc;color:#64748b;font-weight:bold;">Targets</td><td style="padding:12px 15px;background:#f8fafc;color:#16a34a;font-weight:bold;">{{targets_summary}}</td></tr>
<tr><td style="padding:12px 15px;color:#64748b;font-weight:bold;">Risk Distance</td><td style="padding:12px 15px;">{{risk_distance_label}}</td></tr>
<tr><td style="padding:12px 15px;background:#f8fafc;color:#64748b;font-weight:bold;">Asset / Lot Rules</td><td style="padding:12px 15px;background:#f8fafc;">{{asset_type}} • Min lot {{min_lot}} • Step {{lot_step}}</td></tr>
<tr><td style="padding:12px 15px;color:#64748b;font-weight:bold;">Quality Score</td><td style="padding:12px 15px;">{{quality_score}}</td></tr>
<tr><td style="padding:12px 15px;background:#f8fafc;color:#64748b;font-weight:bold;">Session / Volatility</td><td style="padding:12px 15px;background:#f8fafc;">{{session_name}} / {{volatility_status}}</td></tr>
<tr><td style="padding:12px 15px;color:#64748b;font-weight:bold;">MTF / Candle Close</td><td style="padding:12px 15px;">{{mtf_status}} / {{candle_close_status}}</td></tr>
</table>

<div style="margin-top:20px;padding:15px;background:#eff6ff;border-left:4px solid #2563eb;border-radius:10px;font-size:14px;line-height:1.7;">
<strong>Next Step</strong><br>{{next_step}}
</div>

<p style="margin:24px 0 0;color:#64748b;font-size:12px;line-height:1.7;">
Important: This is not financial advice. Use your own risk management before execution.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>';
            }
        }

        return '';
    }

    private static function appendFinalExecutionDetailsToEmail(string $body, array $execution, array $evaluation): string
    {
        $html = '<div style="margin-top:22px;padding:16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;color:#334155;font-size:14px;line-height:1.7;">'
            . '<div style="font-weight:800;font-size:15px;margin-bottom:8px;color:#0f172a;">Execution Engine Details</div>'
            . '<div><strong>Execution Source:</strong> ' . (!empty($execution['dynamic']) ? 'Dynamic institutional recalculation' : 'Stored fallback execution') . '</div>'
            . (!empty($execution['risk_distance']) ? '<div><strong>Risk Distance:</strong> ' . htmlspecialchars((string)$execution['risk_distance'], ENT_QUOTES, 'UTF-8') . '</div>' : '')
            . (!empty($evaluation['risk_reward_user_advisory']) ? '<div><strong>Risk/Reward:</strong> ' . htmlspecialchars((string)$evaluation['risk_reward_user_advisory'], ENT_QUOTES, 'UTF-8') . '</div>' : '')
            . (!empty($execution['atr']) ? '<div><strong>ATR:</strong> ' . htmlspecialchars((string)$execution['atr'], ENT_QUOTES, 'UTF-8') . '</div>' : '')
            . (!empty($execution['reason']) ? '<div><strong>Reason:</strong> ' . htmlspecialchars((string)$execution['reason'], ENT_QUOTES, 'UTF-8') . '</div>' : '')
            . '<div><strong>Quality Reason:</strong> ' . htmlspecialchars((string)($evaluation['quality_reason'] ?? 'N/A'), ENT_QUOTES, 'UTF-8') . '</div>'
            . '<div><strong>MTF Reason:</strong> ' . htmlspecialchars((string)($evaluation['mtf_reason'] ?? 'N/A'), ENT_QUOTES, 'UTF-8') . '</div>'
            . '<div><strong>Candle Close:</strong> ' . htmlspecialchars((string)($evaluation['candle_close_reason'] ?? 'N/A'), ENT_QUOTES, 'UTF-8') . '</div>'
            . '</div>';

        if (stripos($body, '</body>') !== false) {
            return str_ireplace('</body>', $html . '</body>', $body);
        }

        return $body . $html;
    }

    private static function renderTemplate(string $key, array $data): string
    {
        $template = self::getTemplate($key);

        foreach ($data as $k => $v) {
            $template = str_replace('{{' . $k . '}}', (string)$v, $template);
        }

        return $template;
    }

    private static function getTemplate(string $key): string
    {
        try {
            $columns = db()->query("SHOW COLUMNS FROM alert_templates")->fetchAll(PDO::FETCH_COLUMN) ?: [];
            $columns = array_map('strtolower', $columns);

            if (in_array('template_body', $columns, true)) {
                $stmt = db()->prepare("
                    SELECT template_body
                    FROM alert_templates
                    WHERE template_key = ?
                      AND is_active = 1
                    LIMIT 1
                ");
                $stmt->execute([$key]);
                $tpl = $stmt->fetchColumn();

                if ($tpl) {
                    return (string)$tpl;
                }
            }

            if (in_array('body', $columns, true)) {
                $stmt = db()->prepare("
                    SELECT body
                    FROM alert_templates
                    WHERE template_key = ?
                      AND is_active = 1
                    LIMIT 1
                ");
                $stmt->execute([$key]);
                $tpl = $stmt->fetchColumn();

                if ($tpl) {
                    return (string)$tpl;
                }
            }
        } catch (Throwable $e) {
        }

        $fallbacks = [
            'stage_zone_approached_telegram' => "<b>Stage 1: Zone Approached</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n<b>Tracked Zone:</b> {{entry_zone}}\n\n<b>Next Step:</b> Wait for a fresh liquidity sweep.",
            'stage_liquidity_swept_telegram' => "<b>Stage 2: Liquidity Swept</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n<b>Tracked Zone:</b> {{entry_zone}}\n\n<b>Next Step:</b> Wait for displacement / BOS / CHoCH confirmation.",
            'stage_structure_confirmed_telegram' => "<b>Stage 3: Structure Confirmed</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n<b>Tracked Zone:</b> {{entry_zone}}\n\n<b>Next Step:</b> Wait for final candle confirmation and quality filters.",
            'institutional_alert_telegram_body' => "<b>Institutional Setup Ready</b>\n\n<b>{{symbol}} {{side}}</b> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}\n<b>Current Price:</b> {{current_price}}\n<b>Entry Zone:</b> {{entry_zone}}\n<b>New Entry:</b> {{entry_price}}\n<b>Stop Loss:</b> {{stop_loss}}\n<b>Targets:</b> {{targets_summary}}",
            'stage_zone_approached_email' => "<h2>Stage 1: Zone Approached</h2><p><strong>{{symbol}} {{side}}</strong> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}</p><p>Current Price: {{current_price}}</p><p>Tracked Zone: {{entry_zone}}</p><p>Wait for a fresh liquidity sweep.</p>",
            'stage_liquidity_swept_email' => "<h2>Stage 2: Liquidity Swept</h2><p><strong>{{symbol}} {{side}}</strong> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}</p><p>Current Price: {{current_price}}</p><p>Tracked Zone: {{entry_zone}}</p><p>Wait for displacement / BOS / CHoCH confirmation.</p>",
            'stage_structure_confirmed_email' => "<h2>Stage 3: Structure Confirmed</h2><p><strong>{{symbol}} {{side}}</strong> • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}</p><p>Current Price: {{current_price}}</p><p>Tracked Zone: {{entry_zone}}</p><p>Wait for final candle confirmation and quality filters.</p>",
            'institutional_alert_email_body' => "<h2>Institutional Setup Ready</h2><p>{{symbol}} {{side}} • Setup {{setup_timeframe}} • Confirm {{confirmation_timeframe}}</p><p>Entry: {{entry_price}}</p><p>SL: {{stop_loss}}</p><p>Targets: {{targets_summary}}</p>",
        ];

        if (isset($fallbacks[$key])) {
            return $fallbacks[$key];
        }

        return '{{symbol}} {{side}} alert.';
    }

    private static function highestSentStage(array $alert): string
    {
        if (!empty($alert['stage3_sent_at']) || ((int)($alert['stage3_sent_ts'] ?? 0) > 0)) {
            return 'STRUCTURE_CONFIRMED';
        }

        if (!empty($alert['stage2_sent_at']) || ((int)($alert['stage2_sent_ts'] ?? 0) > 0)) {
            return 'LIQUIDITY_SWEPT';
        }

        if (!empty($alert['stage1_sent_at']) || ((int)($alert['stage1_sent_ts'] ?? 0) > 0)) {
            return 'ZONE_APPROACHED';
        }

        return 'TRACKING';
    }

    private static function captureBaseline(int $alertId, string $baselineStage, array $evaluation, array $market): void
    {
        try {
            $nowTs = time();
            $baselineClosedCandleTs = (int)($market['latest_closed_candle_ts'] ?? $market['baseline_closed_candle_ts'] ?? 0);

            db()->prepare("
                UPDATE institutional_alerts
                SET baseline_captured_at = COALESCE(baseline_captured_at, NOW()),
                    baseline_captured_ts = COALESCE(baseline_captured_ts, ?),
                    baseline_closed_candle_ts = COALESCE(baseline_closed_candle_ts, ?),
                    last_processed_closed_candle_ts = COALESCE(?, last_processed_closed_candle_ts),
                    baseline_stage = ?,
                    baseline_zone_touched = ?,
                    baseline_liquidity_swept = ?,
                    baseline_structure_confirmed = ?,
                    baseline_current_price = COALESCE(baseline_current_price, ?),
                    lifecycle_stage = 'TRACKING',
                    stage_event_guard = 'BASELINE_ONLY_NO_ALERT',
                    last_checked_at = NOW(),
                    updated_at = NOW()
                WHERE id = ?
            ")->execute([
                $nowTs,
                $baselineClosedCandleTs ?: null,
                $baselineClosedCandleTs ?: null,
                $baselineStage,
                !empty($evaluation['zone_reached']) || !empty($market['zone_touched']) ? 1 : 0,
                !empty($evaluation['liquidity_swept']) || !empty($market['liquidity_swept']) ? 1 : 0,
                !empty($evaluation['structure_confirmed']) || !empty($market['bos_confirmed']) || !empty($market['choch_confirmed']) ? 1 : 0,
                self::num($market['current_price'] ?? null),
                $alertId
            ]);
        } catch (Throwable $e) {
        }
    }



    private static function markChecked(int $id, string $stage, ?array $market = null, ?array $evaluation = null): void
    {
        try {
            $latestClosedTs = $market !== null ? (int)($market['latest_closed_candle_ts'] ?? 0) : 0;

            $marketJson = $market !== null
                ? json_encode([
                    'current_price' => $market['current_price'] ?? null,
                    'candle_price' => $market['candle_price'] ?? null,
                    'live_crypto_price' => $market['live_crypto_price'] ?? null,
                    'live_stage_price' => $market['live_stage_price'] ?? null,
                    'live_quote_provider' => $market['live_quote_provider'] ?? null,
                    'live_quote_source' => $market['live_quote_source'] ?? null,
                    'price_source' => $market['price_source'] ?? null,
                    'market_open' => !empty($market['market_open']),
                    'market_status_reason' => $market['market_status_reason'] ?? '',
                    'latest_market_open_ts' => $market['latest_market_open_ts'] ?? null,
                    'next_market_open_ts' => $market['next_market_open_ts'] ?? null,
                    'live_quote_time' => $market['live_quote_time'] ?? null,
                    'live_quote_ts' => $market['live_quote_ts'] ?? null,
                    'setup_timeframe' => $market['setup_timeframe'] ?? null,
                    'confirmation_timeframe' => $market['confirmation_timeframe'] ?? null,
                    'scan_timeframe' => $market['scan_timeframe'] ?? null,
                    'using_confirmation_timeframe' => !empty($market['using_confirmation_timeframe']),
                    'confirmation_fresh' => !empty($market['confirmation_fresh']),
                    'setup_fresh' => !empty($market['setup_fresh']),
                    'confirmation_latest_ts' => $market['confirmation_latest_ts'] ?? null,
                    'setup_latest_ts' => $market['setup_latest_ts'] ?? null,
                    'zone_touched' => !empty($market['zone_touched']),
                    'liquidity_swept' => !empty($market['liquidity_swept']),
                    'bos_confirmed' => !empty($market['bos_confirmed']),
                    'choch_confirmed' => !empty($market['choch_confirmed']),
                    'last_candle_is_closed' => !empty($market['last_candle_is_closed']),
                    'closed_candle_time' => $market['closed_candle_time'] ?? null,
                    'latest_closed_candle_ts' => $market['latest_closed_candle_ts'] ?? null,
                    'baseline_closed_candle_ts' => $market['baseline_closed_candle_ts'] ?? null,
                    'stage1_reference_ts' => $market['stage1_reference_ts'] ?? null,
                    'stage2_reference_ts' => $market['stage2_reference_ts'] ?? null,
                    'zone_event_ts' => $market['zone_event_ts'] ?? null,
                    'sweep_event_ts' => $market['sweep_event_ts'] ?? null,
                    'structure_event_ts' => $market['structure_event_ts'] ?? null,
                    'zone_evidence' => $market['zone_evidence'] ?? '',
                    'sweep_evidence' => $market['sweep_evidence'] ?? '',
                    'structure_evidence' => $market['structure_evidence'] ?? '',
                    'stage_guard_note' => $market['stage_guard_note'] ?? '',
                    'stage1_direction_rule' => $market['stage1_direction_rule'] ?? '',
                    'stage1_debug' => $market['stage1_debug'] ?? [],
                    'scan_candle_count' => $market['scan_candle_count'] ?? null,
                ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
                : null;

            $evaluationJson = $evaluation !== null
                ? json_encode($evaluation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
                : null;

            $blockReason = $evaluation['message'] ?? null;

            db()->prepare("
                UPDATE institutional_alerts
                SET lifecycle_stage = ?,
                    last_checked_at = NOW(),
                    last_processed_closed_candle_ts = COALESCE(?, last_processed_closed_candle_ts),
                    stage_event_guard = COALESCE(?, stage_event_guard),
                    last_market_snapshot = COALESCE(?, last_market_snapshot),
                    last_evaluation_snapshot = COALESCE(?, last_evaluation_snapshot),
                    last_block_reason = COALESCE(?, last_block_reason)
                WHERE id = ?
            ")->execute([
                $stage,
                $latestClosedTs ?: null,
                $market['stage_guard_note'] ?? null,
                $marketJson,
                $evaluationJson,
                $blockReason,
                $id
            ]);
        } catch (Throwable $e) {
            try {
                db()->prepare("
                    UPDATE institutional_alerts
                    SET lifecycle_stage = ?,
                        last_checked_at = NOW()
                    WHERE id = ?
                ")->execute([$stage, $id]);
            } catch (Throwable $inner) {
            }
        }
    }




    private static function markTriggered(int $id): void
    {
        try {
            db()->prepare("
                UPDATE institutional_alerts
                SET lifecycle_stage = 'ALERT_SENT',
                    triggered_at = NOW(),
                    final_sent_ts = COALESCE(final_sent_ts, ?),
                    last_sent_at = NOW(),
                    send_count = send_count + 1,
                    is_active = 0
                WHERE id = ?
            ")->execute([time(), $id]);
        } catch (Throwable $e) {
        }
    }



    private static function logAlert(int $id, int $userId, string $channel, string $status, string $message, ?string $signature = null, ?string $stage = null): void
    {
        try {
            db()->prepare("
                INSERT INTO institutional_alert_logs
                (alert_id, user_id, channel, status, message, signature, stage)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            ")->execute([$id, $userId, $channel, $status, $message, $signature, $stage]);
        } catch (Throwable $e) {
            try {
                db()->prepare("
                    INSERT INTO institutional_alert_logs
                    (alert_id, user_id, channel, status, message)
                    VALUES (?, ?, ?, ?, ?)
                ")->execute([$id, $userId, $channel, $status, $message]);
            } catch (Throwable $inner) {
            }
        }
    }

    private static function getSetting(string $key)
    {
        try {
            $stmt = db()->prepare("
                SELECT setting_value
                FROM system_settings
                WHERE setting_key = ?
                LIMIT 1
            ");
            $stmt->execute([$key]);

            $value = $stmt->fetchColumn();
            if ($value !== false) {
                return $value;
            }
        } catch (Throwable $e) {
        }

        try {
            $stmt = db()->prepare("
                SELECT setting_value
                FROM engine_settings
                WHERE setting_group = 'alert_engine'
                  AND setting_key = ?
                LIMIT 1
            ");
            $stmt->execute([$key]);

            $value = $stmt->fetchColumn();
            if ($value !== false) {
                return $value;
            }

            /*
             * Backward-compatible aliases for settings saved by alert_engine.php.
             */
            $aliases = [
                'stage_cooldown_minutes' => 'duplicate_alert_cooldown',
                'final_cooldown_minutes' => 'duplicate_alert_cooldown',
                'stage_alert_throttle_minutes' => 'duplicate_alert_cooldown',
                'final_alert_throttle_minutes' => 'duplicate_alert_cooldown',
                'minimum_quality_score' => 'minimum_confluence_score',
            ];

            if (isset($aliases[$key])) {
                $stmt->execute([$aliases[$key]]);
                $value = $stmt->fetchColumn();

                if ($value !== false) {
                    return $value;
                }
            }
        } catch (Throwable $e) {
        }

        return null;
    }


    private static function developerSimulationEnabled(array $alert = []): bool
    {
        /*
         * Production protection:
         * test_mode/simulate_* must never control normal live tracked alerts.
         * It only applies to rows explicitly marked dev_test_alert=1 and only
         * when allow_test_mode_on_live_alerts is intentionally enabled.
         */
        if (!self::settingEnabled('test_mode', false)) {
            return false;
        }

        if (!self::settingEnabled('allow_test_mode_on_live_alerts', false)) {
            return false;
        }

        return !empty($alert['dev_test_alert']) || !empty($alert['is_test_alert']);
    }

    private static function settingEnabled(string $key, bool $default = false): bool
    {
        $value = self::getSetting($key);

        if ($value === null || $value === '') {
            return $default;
        }

        return in_array(strtolower((string)$value), ['1', 'yes', 'true', 'on'], true);
    }

    private static function containsText(string $haystack, string $needle): bool
    {
        return $needle !== '' && stripos($haystack, $needle) !== false;
    }

    private static function startsWithText(string $haystack, string $needle): bool
    {
        return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0;
    }

    private static function num($v)
    {
        return is_numeric($v) ? (float)$v : null;
    }
    

    private static function resolveExpirationSettings(array $post): array
    {
        $enabled = !empty($post['auto_expire_enabled']) ? 1 : 0;
        $option = strtolower(trim((string)($post['expiration_option'] ?? 'never')));

        if (!$enabled || $option === 'never' || $option === '') {
            return [
                'enabled' => 0,
                'expires_at' => null,
            ];
        }

        $map = [
            '1h' => '+1 hour',
            '4h' => '+4 hours',
            '12h' => '+12 hours',
            '24h' => '+24 hours',
            '48h' => '+48 hours',
            '3d' => '+3 days',
            '1w' => '+1 week',
        ];

        if (!isset($map[$option])) {
            return [
                'enabled' => 0,
                'expires_at' => null,
            ];
        }

        return [
            'enabled' => 1,
            'expires_at' => date('Y-m-d H:i:s', strtotime($map[$option])),
        ];
    }

public static function getPendingSuggestedSetups(int $userId, int $limit = 50): array
    {
        self::ensureTables();
        $limit = max(1, min(200, $limit));
        try {
            self::expireOldSuggestedSetups();
            $stmt = db()->prepare("\n                SELECT *\n                FROM institutional_suggested_setups\n                WHERE user_id = ?\n                  AND status = 'PENDING'\n                  AND (expires_at IS NULL OR expires_at > NOW())\n                ORDER BY quality_score DESC, created_at DESC\n                LIMIT ?\n            ");
            $stmt->bindValue(1, $userId, PDO::PARAM_INT);
            $stmt->bindValue(2, $limit, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
        } catch (Throwable $e) {
            return [];
        }
    }

    public static function ignoreSuggestedSetup(int $userId, int $suggestionId): array
    {
        self::ensureTables();
        try {
            $stmt = db()->prepare("\n                UPDATE institutional_suggested_setups\n                SET status = 'IGNORED', ignored_at = NOW(), updated_at = NOW()\n                WHERE id = ? AND user_id = ? AND status = 'PENDING'\n                LIMIT 1\n            ");
            $stmt->execute([$suggestionId, $userId]);
            return ['ok' => $stmt->rowCount() > 0, 'message' => $stmt->rowCount() > 0 ? 'Suggested setup ignored.' : 'Suggested setup was not found or already handled.'];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    public static function trackSuggestedSetup(int $userId, int $suggestionId, array $options = []): array
    {
        self::ensureTables();
        self::expireOldSuggestedSetups();

        try {
            $stmt = db()->prepare("\n                SELECT *\n                FROM institutional_suggested_setups\n                WHERE id = ?\n                  AND user_id = ?\n                  AND status = 'PENDING'\n                  AND (expires_at IS NULL OR expires_at > NOW())\n                LIMIT 1\n            ");
            $stmt->execute([$suggestionId, $userId]);
            $suggestion = $stmt->fetch(PDO::FETCH_ASSOC);

            if (!$suggestion) {
                return ['ok' => false, 'message' => 'Suggested setup was not found, expired, or already handled.'];
            }

            $telegram = !empty($options['telegram']) ? 1 : 0;
            $email = !empty($options['email']) ? 1 : 0;
            if (!$telegram && !$email) {
                $email = 1;
            }

            $setupKey = 'suggested_' . substr((string)$suggestion['suggestion_uid'], 0, 48);
            $snapshot = [
                'source' => 'derived_suggested_setup',
                'source_alert_id' => (int)$suggestion['source_alert_id'],
                'source_setup_key' => $suggestion['source_setup_key'] ?? null,
                'side' => $suggestion['side'],
                'entry' => self::num($suggestion['suggested_entry_price'] ?? null),
                'entry_low' => self::num($suggestion['suggested_entry_low'] ?? null),
                'entry_high' => self::num($suggestion['suggested_entry_high'] ?? null),
                'stop_loss' => self::num($suggestion['stop_loss'] ?? null),
                'tp1' => self::num($suggestion['tp1'] ?? null),
                'tp2' => self::num($suggestion['tp2'] ?? null),
                'tp3' => self::num($suggestion['tp3'] ?? null),
                'reason' => $suggestion['reason'] ?? '',
                'conditions' => json_decode((string)($suggestion['conditions_json'] ?? ''), true),
            ];

            $trackingStartedTs = time();
            $trackingStartedAt = date('Y-m-d H:i:s', $trackingStartedTs);

            $insert = db()->prepare("\n                INSERT INTO institutional_alerts\n                (user_id, setup_key, symbol, timeframe, style, side, scenario_status,\n                 entry_price, entry_low, entry_high, stop_loss, tp1, tp2, tp3,\n                 alert_trigger_price, liquidity_sweep_level, structure_trigger, scenario_snapshot,\n                 telegram_enabled, email_enabled, tracking_started_at, tracking_started_ts, lifecycle_stage, is_active)\n                VALUES\n                (?, ?, ?, ?, ?, ?, 'Suggested new setup', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'Derived setup accepted by user', ?, ?, ?, ?, ?, 'TRACKING', 1)\n                ON DUPLICATE KEY UPDATE\n                    entry_price = VALUES(entry_price),\n                    entry_low = VALUES(entry_low),\n                    entry_high = VALUES(entry_high),\n                    stop_loss = VALUES(stop_loss),\n                    tp1 = VALUES(tp1),\n                    tp2 = VALUES(tp2),\n                    tp3 = VALUES(tp3),\n                    scenario_snapshot = VALUES(scenario_snapshot),\n                    telegram_enabled = VALUES(telegram_enabled),\n                    email_enabled = VALUES(email_enabled),\n                    tracking_started_at = VALUES(tracking_started_at),\n                    tracking_started_ts = VALUES(tracking_started_ts),\n                    lifecycle_stage = 'TRACKING',\n                    is_active = 1,\n                    triggered_at = NULL,\n                    last_checked_at = NULL,\n                    last_sent_at = NULL,\n                    final_sent_at = NULL,\n                    stage1_sent_at = NULL,\n                    stage2_sent_at = NULL,\n                    stage3_sent_at = NULL,\n                    stage1_sent_ts = NULL,\n                    stage2_sent_ts = NULL,\n                    stage3_sent_ts = NULL,\n                    final_sent_ts = NULL,\n                    baseline_captured_at = NULL,\n                    updated_at = NOW()\n            ");

            $insert->execute([
                $userId,
                $setupKey,
                strtoupper((string)$suggestion['symbol']),
                (string)$suggestion['timeframe'],
                (string)($suggestion['style'] ?? ''),
                strtoupper((string)$suggestion['side']),
                self::num($suggestion['suggested_entry_price'] ?? null),
                self::num($suggestion['suggested_entry_low'] ?? null),
                self::num($suggestion['suggested_entry_high'] ?? null),
                self::num($suggestion['stop_loss'] ?? null),
                self::num($suggestion['tp1'] ?? null),
                self::num($suggestion['tp2'] ?? null),
                self::num($suggestion['tp3'] ?? null),
                json_encode($snapshot, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
                $telegram,
                $email,
                $trackingStartedAt,
                $trackingStartedTs,
            ]);

            db()->prepare("\n                UPDATE institutional_suggested_setups\n                SET status = 'ACCEPTED', accepted_at = NOW(), updated_at = NOW()\n                WHERE id = ? AND user_id = ?\n            ")->execute([$suggestionId, $userId]);

            self::captureImmediateBaselineForTrackedSetup($userId, $setupKey);

            return ['ok' => true, 'message' => 'Suggested setup is now being tracked.', 'setup_key' => $setupKey];
        } catch (Throwable $e) {
            return ['ok' => false, 'message' => $e->getMessage()];
        }
    }

    private static function expireOldSuggestedSetups(): void
    {
        try {
            self::ensureSuggestedSetupsTable();
            db()->exec("\n                UPDATE institutional_suggested_setups\n                SET status = 'EXPIRED', updated_at = NOW()\n                WHERE status = 'PENDING'\n                  AND expires_at IS NOT NULL\n                  AND expires_at <= NOW()\n            ");
        } catch (Throwable $e) {
        }
    }

public static function cleanupExpiredAlerts(): array
    {
        self::ensureTables();

        try {
            $stmt = db()->query("
                SELECT id, timeframe, created_at, expired_at
                FROM institutional_alerts
                WHERE is_active = 1
                  AND triggered_at IS NULL
                  AND expired_at IS NULL
                LIMIT 500
            ");

            $alerts = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
        } catch (Throwable $e) {
            return ['checked' => 0, 'expired' => 0];
        }

        $expired = 0;

        foreach ($alerts as $alert) {
            if (self::alertExpired($alert)) {
                self::expireAlert((int)$alert['id'], 'Setup expired during scheduled cleanup.');
                $expired++;
            }
        }

        return [
            'checked' => count($alerts),
            'expired' => $expired,
        ];
    }


}