<?php

// services/MarketDataService.php
// Multi-provider candle fetcher: TwelveData + FMP + Binance. FMP metals are blocked by default.

require_once __DIR__ . '/../config/database.php';

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

class MarketDataService
{
    public const BUILD = 'MARKET_DATA_METAL_FMP_ROUTING_FIX_V4_2026_06_04';

    public static function fetchCandles($symbol, $interval = '1h', $outputSize = 300, $provider = 'twelvedata')
    {
        $provider = strtolower(trim((string)$provider));
        $provider = $provider ?: 'twelvedata';

        if ($provider === 'auto' || $provider === 'fallback') {
            return self::fetchCandlesAuto($symbol, $interval, $outputSize);
        }

        if ($provider === 'fmp' || $provider === 'financialmodelingprep') {
            $normalizedSymbol = self::normalizeSymbol($symbol);
            $symbolConfig = self::symbolConfig($normalizedSymbol);
            if (self::isCommodityLike($normalizedSymbol, $symbolConfig) && !self::fmpMetalsEnabled()) {
                echo "FMP metal/commodity candle fetch blocked by production routing. Use TwelveData/metals providers for XAUUSD/XAGUSD, or set candle_sync_fmp_metals_enabled=1 only if your FMP subscription supports commodity candles.
";
                return [];
            }
            return self::fetchCandlesFmp($symbol, $interval, $outputSize);
        }

        if ($provider === 'binance') {
            return self::fetchCandlesBinance($symbol, $interval, $outputSize);
        }

        return self::fetchCandlesTwelveData($symbol, $interval, $outputSize);
    }

    public static function fetchCandlesAuto($symbol, $interval = '1h', $outputSize = 300)
    {
        $symbol = self::normalizeSymbol($symbol);
        $symbolConfig = self::symbolConfig($symbol);
        $preferred = strtolower(trim((string)($symbolConfig['provider'] ?? '')));

        $providers = [];

        if ($preferred !== '') {
            $providers[] = $preferred;
        }

        /*
         * For crypto, Binance should usually be the first real fallback because
         * it has strong public crypto candle coverage.
         */
        if (self::isCryptoLike($symbol, $symbolConfig)) {
            $providers[] = 'binance';
        }

        $providers[] = 'twelvedata';
        if (!self::isCommodityLike($symbol, $symbolConfig) || self::fmpMetalsEnabled()) {
            $providers[] = 'fmp';
        }

        if (!self::isCryptoLike($symbol, $symbolConfig)) {
            $providers[] = 'binance';
        }

        $providers = array_values(array_unique($providers));

        foreach ($providers as $provider) {
            ob_start();
            $candles = self::fetchCandles($symbol, $interval, $outputSize, $provider);
            $debug = trim(ob_get_clean());

            if (is_array($candles) && count($candles) > 0) {
                echo "Auto provider selected: {$provider}\n";
                if ($debug !== '') {
                    echo $debug . "\n";
                }
                return $candles;
            }

            echo "Auto provider failed: {$provider}\n";
            if ($debug !== '') {
                echo $debug . "\n";
            }
        }

        return [];
    }

    public static function fetchCandlesBinance($symbol, $interval = '1h', $outputSize = 300)
    {
        $symbol = self::normalizeSymbol($symbol);
        $interval = self::normalizeBinanceInterval($interval);
        $outputSize = max(1, min(1000, (int)$outputSize));

        $symbolConfig = self::symbolConfig($symbol);
        $symbols = self::binanceSymbolCandidates($symbol, $symbolConfig);
        $errors = [];

        foreach ($symbols as $candidate) {
            $params = [
                'symbol' => $candidate,
                'interval' => $interval,
                'limit' => $outputSize,
            ];

            $url = 'https://data-api.binance.vision/api/v3/klines?' . http_build_query($params);
            $response = self::httpGet($url);

            if ($response === false || $response === '') {
                $errors[] = "{$candidate} => failed HTTP request";
                continue;
            }

            $data = json_decode($response, true);

            if (!is_array($data)) {
                $errors[] = "{$candidate} => invalid JSON response. Preview: " . self::previewResponse($response);
                continue;
            }

            if (isset($data['code']) && isset($data['msg'])) {
                $errors[] = "{$candidate} => Binance error {$data['code']}: {$data['msg']}";
                continue;
            }

            if (!$data || !self::isListArray($data)) {
                $errors[] = "{$candidate} => response contained no kline rows";
                continue;
            }

            $candles = self::normalizeBinanceRows($data);

            if (!$candles) {
                $errors[] = "{$candidate} => rows could not be normalized into OHLC candles";
                continue;
            }

            echo "Provider: Binance\n";
            echo "Base URL: https://data-api.binance.vision\n";
            echo "Provider symbol: {$candidate}\n";
            echo "API interval: {$interval}\n";
            echo "Requested candles: {$outputSize}\n";
            echo "Returned candles: " . count($candles) . "\n";
            self::printReturnedRange($candles);

            return $candles;
        }

        echo "Binance returned no usable candles.\n";
        echo "Symbol: {$symbol}\n";
        echo "API interval: {$interval}\n";
        echo "Requested candles: {$outputSize}\n";
        echo "Tried Binance symbols: " . implode(', ', $symbols) . "\n";
        echo "Errors:\n";
        foreach (array_slice($errors, 0, 12) as $error) {
            echo "- {$error}\n";
        }

        echo "Binance note: Binance spot symbols are usually USDT-quoted, for example BTCUSDT, ETHUSDT, SOLUSDT. If your platform symbol is BTCUSD, this service automatically tries BTCUSDT.\n";

        return [];
    }

    public static function fetchCandlesTwelveData($symbol, $interval = '1h', $outputSize = 300)
    {
        $symbol = self::normalizeSymbol($symbol);
        $interval = self::normalizeTwelveDataInterval($interval);
        $outputSize = max(1, min(5000, (int)$outputSize));

        $symbolConfig = self::symbolConfig($symbol);
        $requests = self::twelveDataRequestCandidates($symbol, $symbolConfig);
        $errors = [];
        $accounts = self::providerAccounts('twelvedata', 'candles');

        if (!$accounts) {
            echo "No TwelveData API key row set in api_settings for provider=twelvedata.
";
            return [];
        }

        foreach ($accounts as $account) {
            $apiKey = trim((string)($account['api_key'] ?? ''));
            $rowLabel = self::accountLabel($account);

            if ($apiKey === '') {
                $errors[] = $rowLabel . ' => missing API key';
                continue;
            }

            $accountHadFailure = false;
            $accountLastError = '';

            foreach ($requests as $request) {
                $guard = self::providerAccountAllowed($account, 'candles', 1);
                if (empty($guard['allowed'])) {
                    $errors[] = self::requestLabel($request) . " via {$rowLabel} => " . ($guard['reason'] ?? 'blocked by guard');
                    $accountHadFailure = true;
                    $accountLastError = $guard['reason'] ?? 'blocked by guard';
                    break;
                }

                $params = [
                    'symbol' => $request['symbol'],
                    'interval' => $interval,
                    'outputsize' => $outputSize,
                    'apikey' => $apiKey,
                    'format' => 'JSON',
                    'order' => 'ASC',
                    'timezone' => 'UTC',
                ];

                if (!empty($request['exchange'])) {
                    $params['exchange'] = $request['exchange'];
                }

                $url = "https://api.twelvedata.com/time_series?" . http_build_query($params);
                self::recordAccountUsage($account, 'time_series', $symbol, $interval, 1);
                $response = self::httpGet($url);

                if ($response === false || $response === '') {
                    $msg = self::requestLabel($request) . " via {$rowLabel} => failed HTTP request";
                    $errors[] = $msg;
                    $accountHadFailure = true;
                    $accountLastError = $msg;
                    continue;
                }

                $data = json_decode($response, true);

                if (!is_array($data)) {
                    $msg = self::requestLabel($request) . " via {$rowLabel} => invalid JSON response. Preview: " . self::previewResponse($response);
                    $errors[] = $msg;
                    $accountHadFailure = true;
                    $accountLastError = $msg;
                    continue;
                }

                if (isset($data['code']) || (isset($data['status']) && strtolower((string)$data['status']) === 'error')) {
                    $message = $data['message'] ?? ('API error for ' . self::requestLabel($request));
                    $msg = self::requestLabel($request) . " via {$rowLabel} => " . $message;
                    $errors[] = $msg;
                    $accountHadFailure = true;
                    $accountLastError = $msg;

                    if (self::isProviderLimitMessage((string)$message)) {
                        self::recordAccountRateLimited($account, 'time_series', (string)$message, $symbol, $interval);
                        break;
                    }
                    continue;
                }

                if (empty($data['values']) || !is_array($data['values'])) {
                    $msg = self::requestLabel($request) . " via {$rowLabel} => response contained no values";
                    $errors[] = $msg;
                    $accountHadFailure = true;
                    $accountLastError = $msg;
                    continue;
                }

                $candles = self::normalizeProviderRows($data['values']);

                echo "Provider: TwelveData
";
                echo "Provider account: {$rowLabel}
";
                echo "Provider symbol: " . $request['symbol'] . "
";
                echo "Provider exchange: " . ($request['exchange'] ?? 'none') . "
";
                echo "API interval: {$interval}
";
                echo "Requested candles: {$outputSize}
";
                echo "Returned candles: " . count($candles) . "
";
                self::printReturnedRange($candles);
                self::recordAccountSuccess($account, 'time_series', $symbol, $interval);

                return $candles;
            }

            if ($accountHadFailure && $accountLastError !== '') {
                self::recordAccountFailure($account, 'time_series', $accountLastError, $symbol, $interval);
            }
        }

        echo "TwelveData returned no usable candles.
";
        echo "Symbol: {$symbol}
";
        echo "API interval: {$interval}
";
        echo "Requested candles: {$outputSize}
";
        echo "Tried requests:
";
        foreach ($requests as $request) {
            echo "- " . self::requestLabel($request) . "
";
        }
        echo "Errors:
";
        foreach ($errors as $error) {
            echo "- {$error}
";
        }

        return [];
    }

    public static function fetchCandlesFmp($symbol, $interval = '1h', $outputSize = 300)
    {
        $symbol = self::normalizeSymbol($symbol);
        $requestedInterval = self::normalizeTradingInterval($interval);
        $outputSize = max(1, min(5000, (int)$outputSize));

        $symbolConfig = self::symbolConfig($symbol);
        $isCommodity = self::isCommodityLike($symbol, $symbolConfig);
        if ($isCommodity && !self::fmpMetalsEnabled()) {
            echo "FMP metal/commodity candles are disabled by default. XAUUSD/XAGUSD should use TwelveData/metals providers unless your FMP subscription supports commodity endpoints.
";
            return [];
        }
        $fetchPlan = self::fmpFetchPlan($requestedInterval, $isCommodity, $outputSize);
        $symbols = self::fmpSymbolCandidates($symbol, $symbolConfig, '');
        $accounts = self::providerAccounts('fmp', 'candles');
        $errors = [];

        if (!$accounts) {
            echo "No FMP API key row set in api_settings for provider=fmp.
";
            return [];
        }

        foreach ($accounts as $account) {
            $apiKey = trim((string)($account['api_key'] ?? ''));
            $rowLabel = self::accountLabel($account);

            if ($apiKey === '') {
                $errors[] = "{$rowLabel} => missing API key";
                continue;
            }

            $accountHadFailure = false;
            $accountLastError = '';
            $accountRateLimited = false;

            foreach ($symbols as $candidate) {
                foreach (self::fmpEndpointCandidates($candidate, $fetchPlan['api_interval'], $apiKey) as $url) {
                    $guard = self::providerAccountAllowed($account, 'candles', 1);
                    if (empty($guard['allowed'])) {
                        $msg = "{$candidate} via {$rowLabel} => " . ($guard['reason'] ?? 'FMP candle request blocked by API guard.');
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        $accountRateLimited = true;
                        break 2;
                    }

                    self::recordAccountUsage($account, 'fmp_historical_chart', $symbol, $fetchPlan['api_interval'], 1);
                    $response = self::httpGet($url);

                    if ($response === false || $response === '') {
                        $msg = "{$candidate} via {$rowLabel} => failed HTTP request";
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        continue;
                    }

                    $data = json_decode($response, true);

                    if (!is_array($data)) {
                        $msg = "{$candidate} via {$rowLabel} => invalid JSON response. Preview: " . self::previewResponse($response);
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        continue;
                    }

                    if (isset($data['Error Message'])) {
                        $message = (string)$data['Error Message'];
                        $msg = "{$candidate} via {$rowLabel} => " . $message;
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        if (self::isProviderLimitMessage($message)) {
                            echo "FMP_PROVIDER_LIMIT_REACHED: {$message}
";
                            echo "FMP account {$rowLabel} moved to cooldown; trying the next FMP API key if available.
";
                            self::recordAccountRateLimited($account, 'fmp_historical_chart', $message, $symbol, $fetchPlan['api_interval']);
                            $accountRateLimited = true;
                            break 2;
                        }
                        continue;
                    }

                    if (isset($data['error'])) {
                        $message = is_scalar($data['error']) ? (string)$data['error'] : json_encode($data['error']);
                        $msg = "{$candidate} via {$rowLabel} => " . $message;
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        if (self::isProviderLimitMessage($message)) {
                            echo "FMP_PROVIDER_LIMIT_REACHED: {$message}
";
                            echo "FMP account {$rowLabel} moved to cooldown; trying the next FMP API key if available.
";
                            self::recordAccountRateLimited($account, 'fmp_historical_chart', $message, $symbol, $fetchPlan['api_interval']);
                            $accountRateLimited = true;
                            break 2;
                        }
                        continue;
                    }

                    if (isset($data['message']) && empty($data[0]) && empty($data['historical'])) {
                        $messageText = (string)$data['message'];
                        if (self::isProviderLimitMessage($messageText)) {
                            $msg = "{$candidate} via {$rowLabel} => " . $messageText;
                            $errors[] = $msg;
                            echo "FMP_PROVIDER_LIMIT_REACHED: {$messageText}
";
                            echo "FMP account {$rowLabel} moved to cooldown; trying the next FMP API key if available.
";
                            self::recordAccountRateLimited($account, 'fmp_historical_chart', $messageText, $symbol, $fetchPlan['api_interval']);
                            $accountHadFailure = true;
                            $accountLastError = $msg;
                            $accountRateLimited = true;
                            break 2;
                        }
                        if (stripos($messageText, 'restricted endpoint') !== false || stripos($messageText, 'not available under your current subscription') !== false) {
                            $msg = "{$candidate} via {$rowLabel} => FMP_RESTRICTED_ENDPOINT_DETECTED: " . $messageText;
                        } else {
                            $msg = "{$candidate} via {$rowLabel} => " . $messageText;
                        }
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        continue;
                    }

                    $rows = [];

                    if (isset($data['historical']) && is_array($data['historical'])) {
                        $rows = $data['historical'];
                    } elseif (self::isListArray($data)) {
                        $rows = $data;
                    } elseif (isset($data['values']) && is_array($data['values'])) {
                        $rows = $data['values'];
                    }

                    if (!$rows) {
                        $msg = "{$candidate} via {$rowLabel} => response contained no candle rows";
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        continue;
                    }

                    $candles = self::normalizeProviderRows($rows);

                    if (!$candles) {
                        $msg = "{$candidate} via {$rowLabel} => rows could not be normalized into OHLC candles";
                        $errors[] = $msg;
                        $accountHadFailure = true;
                        $accountLastError = $msg;
                        continue;
                    }

                    if ($fetchPlan['aggregate_to'] !== null) {
                        $before = count($candles);
                        $candles = self::aggregateCandles($candles, $fetchPlan['aggregate_to']);
                        echo "Aggregated FMP candles: {$before} raw {$fetchPlan['api_interval']} candle(s) into " . count($candles) . " {$requestedInterval} candle(s).
";
                    }

                    if (count($candles) > $outputSize) {
                        $candles = array_slice($candles, -$outputSize);
                    }

                    echo "Provider: FMP
";
                    echo "Provider account: {$rowLabel}
";
                    echo "Provider symbol: {$candidate}
";
                    echo "API interval used: {$fetchPlan['api_interval']}
";
                    echo "Requested platform interval: {$requestedInterval}
";
                    echo "Requested candles: {$outputSize}
";
                    echo "Returned candles: " . count($candles) . "
";
                    self::printReturnedRange($candles);
                    self::recordAccountSuccess($account, 'fmp_historical_chart', $symbol, $fetchPlan['api_interval']);

                    return $candles;
                }
            }

            if ($accountHadFailure && !$accountRateLimited && $accountLastError !== '') {
                self::recordAccountFailure($account, 'fmp_historical_chart', $accountLastError, $symbol, $fetchPlan['api_interval']);
            }
        }

        echo "FMP returned no usable candles.
";
        echo "Symbol: {$symbol}
";
        echo "Requested platform interval: {$requestedInterval}
";
        echo "FMP interval used: {$fetchPlan['api_interval']}
";
        echo "Requested candles: {$outputSize}
";
        echo "Tried FMP symbols: " . implode(', ', $symbols) . "
";
        echo "Errors:
";
        foreach (array_slice($errors, 0, 20) as $error) {
            echo "- {$error}
";
        }

        if ($isCommodity) {
            echo "Commodity note: If you see FMP_RESTRICTED_ENDPOINT_DETECTED, the code reached FMP correctly but your current FMP plan does not allow that endpoint.
";
        }

        return [];
    }

    private static function normalizeBinanceRows(array $rows): array
    {
        $candles = [];

        foreach ($rows as $row) {
            if (!is_array($row) || count($row) < 6) {
                continue;
            }

            $openTimeMs = (int)$row[0];
            $candles[] = [
                'open_time' => gmdate('Y-m-d H:i:s', (int)floor($openTimeMs / 1000)),
                'open' => $row[1],
                'high' => $row[2],
                'low' => $row[3],
                'close' => $row[4],
                'volume' => $row[5] ?? 0,
            ];
        }

        usort($candles, function ($a, $b) {
            return strtotime((string)$a['open_time']) <=> strtotime((string)$b['open_time']);
        });

        return $candles;
    }

    private static function binanceSymbolCandidates($symbol, array $symbolConfig = []): array
    {
        $symbol = self::normalizeSymbol($symbol);
        $candidates = [];

        /*
         * If user enters BTCUSD/ETHUSD on this platform, Binance spot normally
         * uses BTCUSDT/ETHUSDT. Keep provider_symbol first if it is configured.
         */
        if (!empty($symbolConfig['provider_symbol'])) {
            $clean = self::normalizeSymbol((string)$symbolConfig['provider_symbol']);
            if ($clean !== '') {
                $candidates[] = $clean;
            }
        }

        if (substr($symbol, -4) === 'USDT') {
            $candidates[] = $symbol;
        }

        if (substr($symbol, -3) === 'USD') {
            $base = substr($symbol, 0, -3);
            $candidates[] = $base . 'USDT';
            $candidates[] = $base . 'BUSD';
            $candidates[] = $symbol;
        }

        $candidates[] = $symbol;

        return array_values(array_unique(array_filter($candidates)));
    }

    private static function fmpFetchPlan(string $requestedInterval, bool $isCommodity, int $outputSize): array
    {
        $requestedInterval = self::normalizeTradingInterval($requestedInterval);

        if ($isCommodity) {
            if ($requestedInterval === '15min' || $requestedInterval === '30min') {
                return ['api_interval' => '5min', 'aggregate_to' => $requestedInterval, 'api_limit' => $outputSize * 6];
            }

            if ($requestedInterval === '4hour') {
                return ['api_interval' => '1hour', 'aggregate_to' => '4hour', 'api_limit' => $outputSize * 4];
            }
        }

        return ['api_interval' => self::normalizeFmpInterval($requestedInterval), 'aggregate_to' => null, 'api_limit' => $outputSize];
    }

    private static function fmpEndpointCandidates(string $symbol, string $interval, string $apiKey): array
    {
        $encoded = rawurlencode($symbol);
        $key = rawurlencode($apiKey);
        $interval = self::normalizeFmpInterval($interval);

        /*
         * Use FMP stable endpoints only. The old /api/v3 historical/quote routes
         * now return a legacy-endpoint error for many current accounts and should
         * not be attempted in production rotation.
         */
        $urls = [];

        if ($interval === '1day') {
            $urls[] = "https://financialmodelingprep.com/stable/historical-price-eod/full?symbol={$encoded}&apikey={$key}";
            $urls[] = "https://financialmodelingprep.com/stable/historical-chart/1day?symbol={$encoded}&apikey={$key}";
            return array_values(array_unique($urls));
        }

        $urls[] = "https://financialmodelingprep.com/stable/historical-chart/{$interval}?symbol={$encoded}&apikey={$key}";

        return array_values(array_unique($urls));
    }

    private static function fmpSymbolCandidates($symbol, array $symbolConfig = [], string $apiKey = ''): array
    {
        $symbol = self::normalizeSymbol($symbol);
        $assetType = strtoupper((string)($symbolConfig['asset_type'] ?? ''));
        $candidates = [];

        $known = [
            'XAUUSD' => ['ZGUSD', 'XAUUSD', 'GCUSD'],
            'XAGUSD' => ['ZIUSD', 'SIUSD', 'XAGUSD', 'XAGUSD.FOREX'],
            'XPTUSD' => ['PLUSD', 'XPTUSD'],
            'XPDUSD' => ['PAUSD', 'XPDUSD'],
            'CLUSD' => ['CLUSD'],
            'BZUSD' => ['BZUSD'],
            'NGUSD' => ['NGUSD'],
        ];

        if (isset($known[$symbol])) {
            foreach ($known[$symbol] as $item) {
                $candidates[] = $item;
            }
        }

        if ($symbol === 'XAGUSD' && $apiKey !== '') {
            foreach (self::discoverFmpCommoditySymbols($apiKey, ['SILVER', 'XAG']) as $discovered) {
                $candidates[] = $discovered;
            }
        }

        if ($symbol === 'XAUUSD' && $apiKey !== '') {
            foreach (self::discoverFmpCommoditySymbols($apiKey, ['GOLD', 'XAU']) as $discovered) {
                $candidates[] = $discovered;
            }
        }

        if (!empty($symbolConfig['provider_symbol'])) {
            $clean = self::normalizeSymbol((string)$symbolConfig['provider_symbol']);
            if ($clean !== '') {
                $candidates[] = $clean;
            }
        }

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

        if ($assetType === 'CRYPTO' && substr($symbol, -3) === 'USD') {
            $candidates[] = $symbol;
        }

        $candidates[] = $symbol;

        return array_values(array_unique(array_filter($candidates)));
    }

    private static function discoverFmpCommoditySymbols(string $apiKey, array $needles): array
    {
        static $cache = [];

        $cacheKey = md5($apiKey . implode('|', $needles));

        if (isset($cache[$cacheKey])) {
            return $cache[$cacheKey];
        }

        $key = rawurlencode($apiKey);
        $url = "https://financialmodelingprep.com/stable/commodities-list?apikey={$key}";
        $response = self::httpGet($url);

        if ($response === false || $response === '') {
            return $cache[$cacheKey] = [];
        }

        $data = json_decode($response, true);

        if (!is_array($data) || !self::isListArray($data)) {
            return $cache[$cacheKey] = [];
        }

        $symbols = [];

        foreach ($data as $row) {
            if (!is_array($row)) {
                continue;
            }

            $blob = strtoupper(implode(' ', array_map('strval', $row)));
            $match = false;

            foreach ($needles as $needle) {
                if ($needle !== '' && strpos($blob, strtoupper($needle)) !== false) {
                    $match = true;
                    break;
                }
            }

            if (!$match) {
                continue;
            }

            foreach (['symbol', 'ticker'] as $field) {
                if (!empty($row[$field])) {
                    $symbols[] = self::normalizeSymbol($row[$field]);
                }
            }
        }

        return $cache[$cacheKey] = array_values(array_unique(array_filter($symbols)));
    }

    private static function normalizeProviderRows(array $rows): array
    {
        $candles = [];

        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $time = $row['date'] ?? $row['datetime'] ?? $row['open_time'] ?? $row['time'] ?? null;
            $open = $row['open'] ?? null;
            $high = $row['high'] ?? null;
            $low = $row['low'] ?? null;
            $close = $row['close'] ?? $row['adjClose'] ?? null;

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

            $candles[] = [
                'open_time' => $time,
                'open' => $open,
                'high' => $high,
                'low' => $low,
                'close' => $close,
                'volume' => $row['volume'] ?? 0,
            ];
        }

        usort($candles, function ($a, $b) {
            return strtotime((string)$a['open_time']) <=> strtotime((string)$b['open_time']);
        });

        return $candles;
    }

    private static function aggregateCandles(array $candles, string $targetInterval): array
    {
        $targetSeconds = self::intervalSeconds($targetInterval);

        if ($targetSeconds <= 0) {
            return $candles;
        }

        $buckets = [];

        foreach ($candles as $candle) {
            $ts = strtotime((string)($candle['open_time'] ?? ''));

            if (!$ts) {
                continue;
            }

            $bucketTs = (int)(floor($ts / $targetSeconds) * $targetSeconds);
            $key = gmdate('Y-m-d H:i:s', $bucketTs);

            if (!isset($buckets[$key])) {
                $buckets[$key] = [
                    'open_time' => $key,
                    'open' => (float)$candle['open'],
                    'high' => (float)$candle['high'],
                    'low' => (float)$candle['low'],
                    'close' => (float)$candle['close'],
                    'volume' => (float)($candle['volume'] ?? 0),
                    '__last_ts' => $ts,
                ];
                continue;
            }

            $buckets[$key]['high'] = max((float)$buckets[$key]['high'], (float)$candle['high']);
            $buckets[$key]['low'] = min((float)$buckets[$key]['low'], (float)$candle['low']);
            $buckets[$key]['volume'] += (float)($candle['volume'] ?? 0);

            if ($ts >= (int)$buckets[$key]['__last_ts']) {
                $buckets[$key]['close'] = (float)$candle['close'];
                $buckets[$key]['__last_ts'] = $ts;
            }
        }

        $out = array_values($buckets);

        usort($out, function ($a, $b) {
            return strtotime((string)$a['open_time']) <=> strtotime((string)$b['open_time']);
        });

        foreach ($out as &$row) {
            unset($row['__last_ts']);
        }
        unset($row);

        return $out;
    }

    private static function intervalSeconds(string $interval): int
    {
        $interval = self::normalizeTradingInterval($interval);

        $map = [
            '1min' => 60,
            '3min' => 180,
            '5min' => 300,
            '15min' => 900,
            '30min' => 1800,
            '45min' => 2700,
            '1hour' => 3600,
            '2hour' => 7200,
            '4hour' => 14400,
            '1day' => 86400,
            '1week' => 604800,
        ];

        return $map[$interval] ?? 0;
    }

    private static function isListArray(array $array): bool
    {
        if ($array === []) {
            return true;
        }

        return array_keys($array) === range(0, count($array) - 1);
    }

    private static function printReturnedRange(array $candles): void
    {
        if (!$candles) {
            return;
        }

        echo "First returned: " . ($candles[0]['open_time'] ?? 'N/A') . "\n";
        echo "Last returned: " . ($candles[count($candles) - 1]['open_time'] ?? 'N/A') . "\n";
    }


    private static function isProviderLimitMessage($message): bool
    {
        $text = strtolower((string)$message);

        return strpos($text, 'limit reach') !== false
            || strpos($text, 'rate limit') !== false
            || strpos($text, 'daily limit') !== false
            || strpos($text, 'api limit') !== false
            || strpos($text, 'please upgrade') !== false
            || strpos($text, 'upgrade your plan') !== false;
    }

    private static function previewResponse($response): string
    {
        $text = trim(strip_tags((string)$response));
        $text = preg_replace('/\s+/', ' ', $text);
        return substr($text, 0, 300);
    }

    private static function twelveDataRequestCandidates($symbol, array $symbolConfig = []): array
    {
        $symbol = self::normalizeSymbol($symbol);
        $providerSymbol = trim((string)($symbolConfig['provider_symbol'] ?? ''));

        $symbols = self::twelveDataSymbolCandidates($symbol, $symbolConfig);
        $requests = [];

        foreach ($symbols as $candidate) {
            $requests[] = ['symbol' => $candidate, 'exchange' => null];
        }

        if (self::isCommodityLike($symbol, $symbolConfig)) {
            $commoditySymbol = $providerSymbol !== '' ? $providerSymbol : self::defaultSlashSymbol($symbol);

            if ($commoditySymbol !== '') {
                $requests[] = ['symbol' => $commoditySymbol, 'exchange' => 'Commodity'];
                $requests[] = ['symbol' => $commoditySymbol, 'exchange' => 'COMMODITY'];
                $requests[] = ['symbol' => $commoditySymbol . ':Commodity', 'exchange' => null];
                $requests[] = ['symbol' => $commoditySymbol . ':COMMODITY', 'exchange' => null];
            }
        }

        $unique = [];
        $final = [];

        foreach ($requests as $request) {
            $key = $request['symbol'] . '|' . ($request['exchange'] ?? '');
            if (!isset($unique[$key])) {
                $unique[$key] = true;
                $final[] = $request;
            }
        }

        return $final;
    }

    private static function twelveDataSymbolCandidates($symbol, array $symbolConfig = []): array
    {
        $symbol = self::normalizeSymbol($symbol);
        $assetType = strtoupper((string)($symbolConfig['asset_type'] ?? ''));

        $candidates = [];

        if (!empty($symbolConfig['provider_symbol'])) {
            $candidates[] = trim((string)$symbolConfig['provider_symbol']);
        }

        if (class_exists('SystemConfigService') && method_exists('SystemConfigService', 'defaultProviderSymbol')) {
            $default = SystemConfigService::defaultProviderSymbol($symbol, $assetType, 'twelvedata');
            if ($default !== '') {
                $candidates[] = $default;
            }
        }

        $slash = self::defaultSlashSymbol($symbol);
        if ($slash !== '') {
            $candidates[] = $slash;
        }

        $candidates[] = $symbol;

        return array_values(array_unique(array_filter($candidates)));
    }

    private static function requestLabel(array $request): string
    {
        if (!empty($request['exchange'])) {
            return $request['symbol'] . ' exchange=' . $request['exchange'];
        }

        return $request['symbol'];
    }

    private static function isCommodityLike($symbol, array $symbolConfig = []): bool
    {
        $symbol = self::normalizeSymbol($symbol);
        $assetType = strtoupper((string)($symbolConfig['asset_type'] ?? ''));

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

        return in_array(substr($symbol, 0, 3), ['XAU', 'XAG', 'XPT', 'XPD'], true);
    }

    private static function isCryptoLike($symbol, array $symbolConfig = []): bool
    {
        $symbol = self::normalizeSymbol($symbol);
        $assetType = strtoupper((string)($symbolConfig['asset_type'] ?? ''));

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

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

    private static function httpGet(string $url)
    {
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 15,
                CURLOPT_TIMEOUT => 30,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_USERAGENT => 'InstitutionalTradingAssistant/1.0',
                CURLOPT_HTTPHEADER => ['Accept: application/json'],
            ]);

            $response = curl_exec($ch);
            curl_close($ch);

            if ($response !== false && $response !== '') {
                return $response;
            }
        }

        $context = stream_context_create([
            'http' => [
                'header' => "Accept: application/json\r\nUser-Agent: InstitutionalTradingAssistant/1.0\r\n",
                'timeout' => 30,
            ],
        ]);

        return @file_get_contents($url, false, $context);
    }

    private static function providerAccounts(string $provider, string $purpose): array
    {
        $provider = strtolower(trim($provider));

        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'providerRows')) {
            $rows = ApiCreditGuard::providerRows($provider, $purpose, 1, true);
            if ($rows) {
                return $rows;
            }
        }

        $apiKey = self::apiKey($provider);
        if (!$apiKey && $provider === 'fmp') {
            $apiKey = self::apiKey('financialmodelingprep');
        }

        if ($apiKey) {
            return [[
                'id' => 0,
                'provider' => $provider,
                'api_key' => $apiKey,
                'supports_' . ($purpose === 'candles' ? 'candles' : 'quote') => 1,
                'is_active' => 1,
            ]];
        }

        return [];
    }

    private static function accountLabel(array $account): string
    {
        $provider = strtolower(trim((string)($account['provider'] ?? 'provider')));
        $id = (int)($account['id'] ?? 0);
        return $id > 0 ? strtoupper($provider) . ' row #' . $id : strtoupper($provider);
    }

    private static function providerAccountAllowed(array $account, string $purpose, int $credits = 1): array
    {
        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'shouldUseProviderRow') && !empty($account['id'])) {
            return ApiCreditGuard::shouldUseProviderRow($account, $purpose, $credits);
        }

        if (class_exists('ApiCreditGuard')) {
            return ApiCreditGuard::shouldUseProvider((string)($account['provider'] ?? ''), $purpose, $credits);
        }

        return ['allowed' => true, 'reason' => 'No ApiCreditGuard available.'];
    }

    private static function recordAccountUsage(array $account, string $endpoint, string $symbol, string $timeframe, int $credits = 1): void
    {
        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'recordUsageForRow') && !empty($account['id'])) {
            ApiCreditGuard::recordUsageForRow($account, $credits, $endpoint, $symbol, $timeframe);
        } elseif (class_exists('ApiCreditGuard')) {
            ApiCreditGuard::recordUsage((string)($account['provider'] ?? ''), $credits, $endpoint, $symbol, $timeframe);
        }
    }

    private static function recordAccountSuccess(array $account, string $endpoint, string $symbol, string $timeframe): void
    {
        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'recordSuccessForRow') && !empty($account['id'])) {
            ApiCreditGuard::recordSuccessForRow($account, $endpoint, $symbol, $timeframe);
        } elseif (class_exists('ApiCreditGuard')) {
            ApiCreditGuard::recordSuccess((string)($account['provider'] ?? ''), $endpoint, $symbol, $timeframe);
        }
    }

    private static function recordAccountFailure(array $account, string $endpoint, string $error, string $symbol, string $timeframe): void
    {
        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'recordFailureForRow') && !empty($account['id'])) {
            ApiCreditGuard::recordFailureForRow($account, $error, $endpoint, $symbol, $timeframe);
        } elseif (class_exists('ApiCreditGuard')) {
            ApiCreditGuard::recordFailure((string)($account['provider'] ?? ''), $error, $endpoint, $symbol, $timeframe);
        }
    }

    private static function recordAccountRateLimited(array $account, string $endpoint, string $error, string $symbol, string $timeframe): void
    {
        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'recordRateLimitedForRow') && !empty($account['id'])) {
            ApiCreditGuard::recordRateLimitedForRow($account, $error, 60, $endpoint, $symbol, $timeframe);
        } elseif (class_exists('ApiCreditGuard')) {
            ApiCreditGuard::recordFailure((string)($account['provider'] ?? ''), $error, $endpoint, $symbol, $timeframe);
        }
    }

    private static function fmpMetalsEnabled(): bool
    {
        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'boolSetting')) {
            return ApiCreditGuard::boolSetting('candle_sync_fmp_metals_enabled', false)
                || ApiCreditGuard::boolSetting('market_data_fmp_metals_enabled', false);
        }

        try {
            $stmt = db()->prepare("SELECT setting_value FROM system_settings WHERE setting_key IN ('candle_sync_fmp_metals_enabled','market_data_fmp_metals_enabled') ORDER BY FIELD(setting_key,'candle_sync_fmp_metals_enabled','market_data_fmp_metals_enabled') LIMIT 1");
            $stmt->execute();
            $value = strtolower(trim((string)$stmt->fetchColumn()));
            return in_array($value, ['1','true','yes','on','enabled'], true);
        } catch (Throwable $e) {
            return false;
        }
    }

    private static function apiKey(string $provider): ?string
    {
        $provider = strtolower(trim($provider));

        if (class_exists('ApiCreditGuard') && method_exists('ApiCreditGuard', 'providerRow')) {
            $row = ApiCreditGuard::providerRow($provider, 'candles', 1);
            if (!$row) {
                $row = ApiCreditGuard::providerRow($provider, 'quote', 1);
            }
            if ($row && !empty($row['api_key'])) {
                return trim((string)$row['api_key']);
            }
        }

        try {
            if (class_exists('SystemConfigService') && method_exists('SystemConfigService', 'getApiKey')) {
                $key = trim((string)SystemConfigService::getApiKey($provider));
                if ($key !== '') {
                    return $key;
                }
            }
        } catch (Throwable $e) {
        }

        try {
            $stmt = db()->prepare("SELECT api_key FROM api_settings WHERE LOWER(provider) = LOWER(?) AND COALESCE(is_active, 1) = 1 ORDER BY COALESCE(priority_order, 999999) ASC, COALESCE(used_today,0) ASC, id ASC LIMIT 1");
            $stmt->execute([$provider]);
            $key = $stmt->fetchColumn();
            return $key ? trim((string)$key) : null;
        } catch (Throwable $e) {
            return null;
        }
    }

    private static function symbolConfig(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 [];
    }

    public static function formatSymbol($symbol, array $symbolConfig = [], $provider = 'twelvedata')
    {
        $provider = strtolower(trim((string)$provider));

        if ($provider === 'fmp' || $provider === 'financialmodelingprep') {
            $candidates = self::fmpSymbolCandidates($symbol, $symbolConfig);
            return $candidates[0] ?? self::normalizeSymbol($symbol);
        }

        if ($provider === 'binance') {
            $candidates = self::binanceSymbolCandidates($symbol, $symbolConfig);
            return $candidates[0] ?? self::normalizeSymbol($symbol);
        }

        $candidates = self::twelveDataSymbolCandidates($symbol, $symbolConfig);
        return $candidates[0] ?? self::normalizeSymbol($symbol);
    }

    private static function defaultSlashSymbol($symbol): string
    {
        $symbol = self::normalizeSymbol($symbol);

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

        if (substr($symbol, -3) === 'USD' && strlen($symbol) > 3) {
            return substr($symbol, 0, -3) . '/USD';
        }

        return '';
    }

    public static function normalizeSymbol($symbol): string
    {
        return strtoupper(str_replace(['/', '-', '_', ' '], '', trim((string)$symbol)));
    }

    private static function normalizeTradingInterval($interval): string
    {
        $raw = strtolower(trim((string)$interval));
        $raw = str_replace([' ', '_'], '', $raw);

        $map = [
            '1m' => '1min', 'm1' => '1min', '1min' => '1min',
            '3m' => '3min', 'm3' => '3min', '3min' => '3min',
            '5m' => '5min', 'm5' => '5min', '5min' => '5min',
            '15m' => '15min', 'm15' => '15min', '15min' => '15min',
            '30m' => '30min', 'm30' => '30min', '30min' => '30min',
            '45m' => '45min', '45min' => '45min',
            '1h' => '1hour', 'h1' => '1hour', '1hour' => '1hour',
            '2h' => '2hour', 'h2' => '2hour', '2hour' => '2hour',
            '4h' => '4hour', 'h4' => '4hour', '4hour' => '4hour',
            '1d' => '1day', 'd1' => '1day', '1day' => '1day',
            '1w' => '1week', 'w1' => '1week', '1week' => '1week',
            '1mo' => '1month', 'mn1' => '1month', '1month' => '1month',
        ];

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

    public static function normalizeTwelveDataInterval($interval): string
    {
        $interval = self::normalizeTradingInterval($interval);

        $map = [
            '1hour' => '1h',
            '2hour' => '2h',
            '4hour' => '4h',
            '1day' => '1day',
            '1week' => '1week',
            '1month' => '1month',
        ];

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

    public static function normalizeFmpInterval($interval): string
    {
        $interval = self::normalizeTradingInterval($interval);

        $supported = ['1min', '5min', '15min', '30min', '1hour', '4hour', '1day'];
        return in_array($interval, $supported, true) ? $interval : '1hour';
    }

    public static function normalizeBinanceInterval($interval): string
    {
        $interval = self::normalizeTradingInterval($interval);

        $map = [
            '1min' => '1m',
            '3min' => '3m',
            '5min' => '5m',
            '15min' => '15m',
            '30min' => '30m',
            '1hour' => '1h',
            '2hour' => '2h',
            '4hour' => '4h',
            '1day' => '1d',
            '1week' => '1w',
            '1month' => '1M',
        ];

        return $map[$interval] ?? '1h';
    }

    /*
     * Backwards-compatible alias used by older files.
     */
    public static function normalizeInterval($interval): string
    {
        return self::normalizeTwelveDataInterval($interval);
    }
}
