import asyncio
import socket
import time
from collections import defaultdict
from functools import reduce
from pathlib import Path
from threading import Thread
from typing import cast

import pandas as pd

from qubx import QubxLogConfig, logger
from qubx.backtester.optimization import variate
from qubx.backtester.runner import SimulationRunner
from qubx.backtester.simulator import simulate
from qubx.backtester.utils import (
    SetupTypes,
    SimulatedLogFormatter,
    SimulationConfigError,
    SimulationSetup,
    recognize_simulation_data_config,
)
from qubx.connectors.registry import ConnectorRegistry
from qubx.core.account import CompositeAccountProcessor
from qubx.core.basics import (
    CtrlChannel,
    Instrument,
    LiveTimeProvider,
    Position,
    RestoredState,
    TransactionCostsCalculator,
)
from qubx.core.context import StrategyContext
from qubx.core.helpers import BasicScheduler
from qubx.core.initializer import BasicStrategyInitializer
from qubx.core.interfaces import (
    IAccountProcessor,
    IBroker,
    IDataProvider,
    IHealthMonitor,
    IStrategyContext,
    ITimeProvider,
)
from qubx.core.loggers import StrategyLogging
from qubx.core.lookups import lookup, register_accounts
from qubx.core.mixins.utils import EXCHANGE_MAPPINGS
from qubx.data.cache import CachedStorage, MemoryCache
from qubx.data.storages.stub import NoConfiguredStorage
from qubx.health import BaseHealthMonitor
from qubx.loggers import create_logs_writer
from qubx.restarts.state_resolvers import StateResolver
from qubx.restarts.time_finders import TimeFinder
from qubx.restorers import create_state_restorer
from qubx.utils.misc import class_import, green, install_uvloop, makedirs, red
from qubx.utils.results import (
    SimulationResultsSaver,
    is_cloud_path,
    normalize_tags,
)
from qubx.utils.runner.configs import (
    ExchangeConfig,
    LiveConfig,
    LoggingConfig,
    PrefetchConfig,
    RestorerConfig,
    StorageConfig,
    StrategyConfig,
    WarmupConfig,
    load_strategy_config_from_yaml,
    normalize_aux_config,
    resolve_aux_config,
)
from qubx.utils.runner.factory import (
    construct_multi_storage,
    construct_storage,
    create_data_type_storages,
    create_exporters,
    create_metric_emitters,
    create_notifiers,
    create_state_persistence,
)
from qubx.utils.time import convert_seconds_to_str

from .accounts import AccountConfigurationManager

INVERSE_EXCHANGE_MAPPINGS = {mapping: exchange for exchange, mapping in EXCHANGE_MAPPINGS.items()}


def _cleanup_event_loop(loop: asyncio.AbstractEventLoop | None) -> None:
    """
    Cleanup the shared event loop.

    Args:
        loop: The event loop to cleanup. If None, does nothing.
    """
    if loop is None:
        return

    try:
        if not loop.is_closed():
            loop.call_soon_threadsafe(loop.stop)
            logger.debug("Shared event loop stopped")
    except Exception as e:
        logger.warning(f"Failed to cleanup event loop: {e}")


def run_strategy_yaml(
    config_file: Path,
    account_file: Path | None = None,
    paper: bool = False,
    restore: bool = False,
    blocking: bool = False,
    no_color: bool = False,
    no_emission: bool = False,
    no_notifiers: bool = False,
    no_exporters: bool = False,
    config_overrides: Path | None = None,
) -> IStrategyContext:
    """
    Run the strategy with the given configuration file.

    Args:
        config_file (Path): The path to the configuration file.
        account_file (Path, optional): The path to the account configuration file. Defaults to None.
        paper (bool, optional): Whether to run in paper trading mode. Defaults to False.
        no_color (bool, optional): Whether to disable colored logging. Defaults to False.
    """
    if not config_file.exists():
        raise FileNotFoundError(f"Configuration file not found: {config_file}")
    if account_file is not None and not account_file.exists():
        raise FileNotFoundError(f"Account configuration file not found: {account_file}")

    # Register built-in connectors and load plugins
    import qubx.connectors  # noqa: F401, I001 - registers ccxt/tardis/xlighter connectors
    from qubx.plugins import load_plugins  # noqa: I001

    acc_manager = AccountConfigurationManager(account_file, config_file.parent, search_qubx_dir=True)
    stg_config = load_strategy_config_from_yaml(config_file, overrides_path=config_overrides)

    # Load plugins from configuration
    load_plugins(stg_config.plugins)

    return run_strategy(
        stg_config,
        acc_manager,
        paper=paper,
        restore=restore,
        blocking=blocking,
        no_color=no_color,
        no_emission=no_emission,
        no_notifiers=no_notifiers,
        no_exporters=no_exporters,
    )


def run_strategy_yaml_in_jupyter(
    config_file: Path,
    account_file: Path | None = None,
    paper: bool = False,
    restore: bool = False,
    no_emission: bool = False,
    no_notifiers: bool = False,
    no_exporters: bool = False,
) -> None:
    """
    Run a strategy in a Jupyter notebook.

    Args:
        config_file: Path to the strategy configuration file
        account_file: Path to the account configuration file
        paper: Whether to run in paper trading mode
        restore: Whether to restore the strategy state
    """
    if not config_file.exists():
        logger.error(f"Configuration file not found: {config_file}")
        return
    try:
        import nest_asyncio

        nest_asyncio.apply()
    except ImportError:
        logger.error("Can't find <r>nest_asyncio</r> module - try to install it first")
        return

    try:
        from jupyter_console.app import ZMQTerminalIPythonApp
    except ImportError:
        logger.error(
            "Can't find <r>ZMQTerminalIPythonApp</r> module - try to install <g>jupyter-console</g> package first"
        )
        return

    class TerminalRunner(ZMQTerminalIPythonApp):
        def __init__(self, **kwargs) -> None:
            self.init_code = kwargs.pop("init_code")
            super().__init__(**kwargs)

        def init_banner(self):
            pass

        def initialize(self, argv=None):
            super().initialize(argv=[])
            self.shell.run_cell(self.init_code)

    _base = Path(__file__).parent.absolute()
    with open(_base / "_jupyter_runner.pyt", "r") as f:
        content = f.read()

    content_with_values = content.format_map(
        {
            "config_file": config_file,
            "account_file": account_file,
            "paper": paper,
            "restore": restore,
            "no_emission": no_emission,
            "no_notifiers": no_notifiers,
            "no_exporters": no_exporters,
        }
    )
    logger.info("Running in Jupyter console")
    TerminalRunner.launch_instance(init_code=content_with_values)


def run_strategy(
    config: StrategyConfig,
    account_manager: AccountConfigurationManager,
    paper: bool = False,
    restore: bool = False,
    blocking: bool = False,
    no_color: bool = False,
    no_emission: bool = False,
    no_notifiers: bool = False,
    no_exporters: bool = False,
) -> IStrategyContext:
    """
    Run a strategy with the given configuration.

    Args:
        config (StrategyConfig): The configuration of the strategy.
        account_manager (AccountManager): The account manager to use.
        paper (bool, optional): Whether to run in paper trading mode. Defaults to False.
        blocking (bool, optional): Whether to block the main thread. Defaults to False.
        no_color (bool, optional): Whether to disable colored logging. Defaults to False.

    Returns:
        IStrategyContext: The strategy context.
    """
    # Validate that live configuration exists
    if not config.live:
        raise ValueError("Live configuration is required for strategy execution")

    # Install uvloop and create shared event loop
    install_uvloop()
    loop = asyncio.new_event_loop()
    loop_thread = Thread(target=loop.run_forever, daemon=True, name="SharedEventLoop")
    loop_thread.start()
    logger.debug("Shared event loop started in background thread")

    register_accounts(account_manager)

    QubxLogConfig.setup_logger(
        level=QubxLogConfig.get_log_level(),
        custom_formatter=(simulated_formatter := SimulatedLogFormatter(LiveTimeProvider())).formatter,
        colorize=not no_color,
    )

    # Start health server early so liveness probe works during init/warmup
    from qubx.config import settings as _qubx_settings

    _health_server = None
    _health_ctx_ref: list[IStrategyContext | None] = [None]  # mutable ref for closure

    if _qubx_settings.health_port:
        from qubx.health import HealthServer

        def _ready_check() -> bool:
            c = _health_ctx_ref[0]
            return c is not None and c._strategy_state.is_on_warmup_finished_called

        _health_server = HealthServer(_qubx_settings.health_port, ready_check=_ready_check)
        _health_server.start()

    # Restore state if configured
    restored_state = _restore_state(config.live.warmup.restorer if config.live.warmup else None) if restore else None

    # Resolve aux config with live section override (needed for both warmup and live context)
    aux_configs = resolve_aux_config(config.aux, getattr(config.live, "aux", None))

    # Create the strategy context
    ctx = create_strategy_context(
        config=config,
        account_manager=account_manager,
        paper=paper,
        restored_state=restored_state,
        simulated_formatter=simulated_formatter,
        no_color=no_color,
        aux_configs=aux_configs,
        loop=loop,
        no_emission=no_emission,
        no_notifiers=no_notifiers,
        no_exporters=no_exporters,
    )
    _health_ctx_ref[0] = ctx  # expose to health server ready_check

    try:
        _run_warmup(
            ctx=ctx,
            restored_state=restored_state,
            exchanges=config.live.exchanges,
            warmup=config.live.warmup,
            prefetch_config=config.live.prefetch,
            simulated_formatter=simulated_formatter,
            account_manager=account_manager,
            enable_funding=config.live.warmup.enable_funding if config.live.warmup else False,
        )
    except KeyboardInterrupt:
        logger.info("Warmup interrupted by user")
        _cleanup_event_loop(loop)
        return ctx
    except Exception as e:
        logger.error(f"Warmup failed: {e}")
        _cleanup_event_loop(loop)
        raise e

    _apply_base_live_subscription(ctx)

    # Start the strategy context
    if blocking:
        try:
            ctx.start(blocking=True)
        except KeyboardInterrupt:
            logger.info("Stopped by user")
        finally:
            ctx.stop()
            if _health_server:
                _health_server.stop()
            _cleanup_event_loop(loop)
    else:
        ctx.start()

    return ctx


def _restore_state(restorer_config: RestorerConfig | None) -> RestoredState | None:
    if restorer_config is None:
        restorer_config = RestorerConfig(type="CsvStateRestorer", parameters={"base_dir": "logs"})

    state_restorer = create_state_restorer(
        restorer_config.type,
        restorer_config.parameters,
    )

    state = state_restorer.restore_state()
    logger.info(
        f"<yellow>Restored state with {len(state.positions)} positions "
        f"and {sum(len(s) for s in state.instrument_to_signal_positions.values())} signals</yellow>"
    )
    logger.info("<yellow> - Positions:</yellow>")
    for position in state.positions.values():
        logger.info(f"<yellow>   - {position}</yellow>")
    return state


def create_strategy_context(
    config: StrategyConfig,
    account_manager: AccountConfigurationManager,
    paper: bool,
    restored_state: RestoredState | None,
    simulated_formatter: SimulatedLogFormatter,
    no_color: bool = False,
    aux_configs: list[StorageConfig] | None = None,
    loop: asyncio.AbstractEventLoop | None = None,
    no_emission: bool = False,
    no_notifiers: bool = False,
    no_exporters: bool = False,
) -> IStrategyContext:
    """
    Create a strategy context from the given configuration.
    """
    # Validate that live configuration exists
    if not config.live:
        raise ValueError("Live configuration is required for strategy execution")

    stg_name = _get_strategy_name(config)
    _run_mode = "paper" if paper else "live"

    # Generate run_id once to be shared between logging and metric emissions
    run_id = f"{socket.gethostname()}-{str(int(time.time() * 10**9))}"

    if isinstance(config.strategy, list):
        _strategy_class = reduce(lambda x, y: x + y, [class_import(x) for x in config.strategy])
    elif isinstance(config.strategy, str):
        _strategy_class = class_import(config.strategy)
    else:
        _strategy_class = config.strategy

    _logging = _setup_strategy_logging(stg_name, config.live.logging, simulated_formatter, run_id)

    # --- Platform identity from unified settings ---
    from qubx.config import settings as qubx_settings

    _bot_id = qubx_settings.bot_id
    _instance_id = qubx_settings.instance_id or socket.gethostname()
    _platform_tags: dict[str, str] = {}
    if _bot_id:
        _platform_tags["bot_id"] = _bot_id
    _platform_tags["instance_id"] = _instance_id

    # Bind platform identity to all log messages
    QubxLogConfig.bind_platform_identity(_bot_id, _instance_id)

    # Create metric emitters with run_id as a tag
    if no_emission:
        logger.info("Metric emission disabled via CLI flag")
        _metric_emitter = None
    else:
        _metric_emitter = (
            create_metric_emitters(config.live.emission, stg_name, run_id, extra_tags=_platform_tags)
            if config.live.emission
            else None
        )

        # Auto-enable Prometheus from QUBX_METRICS_PORT if no emitter was configured
        if _metric_emitter is None and qubx_settings.metrics_port:
            from qubx.emitters.prometheus import PrometheusMetricEmitter

            _prom_tags = {"strategy": stg_name, "run_id": run_id or "", **_platform_tags}
            _metric_emitter = PrometheusMetricEmitter(
                strategy_name=stg_name,
                expose_http=True,
                http_port=qubx_settings.metrics_port,
                tags=_prom_tags,
            )
            logger.info(f"Auto-enabled Prometheus metrics on port {qubx_settings.metrics_port} (QUBX_METRICS_PORT)")

    # Create lifecycle notifiers
    if no_notifiers:
        logger.info("Lifecycle notifiers disabled via CLI flag")
        _notifier = None
    else:
        _notifier = create_notifiers(config.live.notifiers, stg_name) if config.live.notifiers else None

    # Create strategy initializer
    _initializer = BasicStrategyInitializer()

    _time = LiveTimeProvider()
    _chan = CtrlChannel("databus", sentinel=(None, None, None, None))
    _sched = BasicScheduler(_chan, lambda: _time.time().item())

    # Create health metrics monitor with emitter
    _health_monitor = BaseHealthMonitor(
        _time, emitter=_metric_emitter, channel=_chan, **config.live.health.model_dump()
    )

    exchanges = list(config.live.exchanges.keys())

    _exchange_to_tcc = {}
    _exchange_to_broker = {}
    _exchange_to_data_provider = {}
    _exchange_to_account = {}
    _instruments = []

    for exchange_name, exchange_config in config.live.exchanges.items():
        _exchange_to_tcc[exchange_name] = (tcc := _create_tcc(exchange_name, account_manager))
        _exchange_to_data_provider[exchange_name] = (
            data_provider := _create_data_provider(
                exchange_name,
                exchange_config,
                time_provider=_time,
                channel=_chan,
                account_manager=account_manager,
                health_monitor=_health_monitor,
                loop=loop,
            )
        )
        _exchange_to_account[exchange_name] = (
            account := _create_account_processor(
                exchange_name,
                exchange_config,
                channel=_chan,
                time_provider=_time,
                account_manager=account_manager,
                tcc=tcc,
                paper=paper,
                health_monitor=_health_monitor,
                live_config=config.live,
                data_provider=data_provider,
                restored_state=restored_state.filter_by_exchange(exchange_name) if restored_state else None,
                read_only=config.live.read_only,
                loop=loop,
            )
        )
        _exchange_to_broker[exchange_name] = _create_broker(
            exchange_name,
            exchange_config,
            _chan,
            time_provider=_time,
            account=account,
            data_provider=data_provider,
            account_manager=account_manager,
            health_monitor=_health_monitor,
            paper=paper,
            loop=loop,
        )
        _instruments.extend(_create_instruments_for_exchange(exchange_name, exchange_config))

    # Use provided aux_configs or resolve if not provided (for backwards compatibility)
    if aux_configs is None:
        aux_configs = resolve_aux_config(config.aux, getattr(config.live, "aux", None))

    # - create aux storage from config
    _aux_storage = construct_multi_storage(aux_configs)

    # - construct CachedStorage(CachedReader) here instead of CachedPrefetchReader.
    if _aux_storage is not None and config.live.prefetch:
        prefetch_config = config.live.prefetch
        if prefetch_config.enabled:
            _aux_storage = CachedStorage(
                _aux_storage,
                prefetch_period=prefetch_config.prefetch_period,
                cache_factory=lambda: MemoryCache(max_size_mb=prefetch_config.cache_size_mb),
            )

    # - when no any aux storage is configured let's use empty one
    # - we don't raise an exception here because some strategies may not require aux data
    # - but if strategy asks for aux data it would rise an error with a clear message about missing aux storage configuration
    if _aux_storage is None:
        _aux_storage = NoConfiguredStorage(
            f"Strategy {config.name or ''} is trying to access aux data bit no auxiliary storage configured for live mode"
        )

    _account = (
        CompositeAccountProcessor(_time, _exchange_to_account)
        if len(exchanges) > 1
        else _exchange_to_account[exchanges[0]]
    )
    _initializer = BasicStrategyInitializer(simulation=_exchange_to_data_provider[exchanges[0]].is_simulation)

    # Create exporters if configured
    if no_exporters:
        logger.info("Trade exporters disabled via CLI flag")
        _exporter = None
    else:
        _exporter = create_exporters(config.live.exporters, stg_name, _account) if config.live.exporters else None

    # Create data throttler from config
    _data_throttler = _create_data_throttler(config.live.throttling) if config.live.throttling else None

    # Create state persistence if configured
    _state_persistence = create_state_persistence(config.live.state, stg_name)

    logger.info(f"- Strategy: <blue>{stg_name}</blue>\n- Mode: {_run_mode}\n- Parameters: {config.parameters}")

    ctx = StrategyContext(
        strategy=_strategy_class,  # type: ignore
        brokers=list(_exchange_to_broker.values()),
        data_providers=list(_exchange_to_data_provider.values()),
        account=_account,
        scheduler=_sched,
        time_provider=_time,
        instruments=_instruments,
        logging=_logging,
        config=config.parameters,
        aux_data_storage=_aux_storage,
        exporter=_exporter,
        emitter=_metric_emitter,
        notifier=_notifier,
        initializer=_initializer,
        strategy_name=stg_name,
        health_monitor=_health_monitor,
        restored_state=restored_state,
        data_throttler=_data_throttler,
        state_persistence=_state_persistence,
    )

    # Store the shared event loop reference for cleanup
    if loop is not None:
        ctx._shared_event_loop = loop  # type: ignore

    # Set context for metric emitters to enable is_live tag and time access
    if _metric_emitter is not None:
        _metric_emitter.set_context(ctx)

    return ctx


def _get_strategy_name(cfg: StrategyConfig) -> str:
    if cfg.name is not None:
        return cfg.name
    if isinstance(cfg.strategy, list):
        return "_".join(map(lambda x: x.split(".")[-1], cfg.strategy))
    elif isinstance(cfg.strategy, str):
        return cfg.strategy.split(".")[-1]
    else:
        return cfg.strategy.__class__.__name__


def _setup_strategy_logging(
    stg_name: str,
    log_config: LoggingConfig,
    simulated_formatter: SimulatedLogFormatter,
    run_id: str,
) -> StrategyLogging:
    if not hasattr(log_config, "args") or not isinstance(log_config.args, dict):
        log_config.args = {}
    log_id = time.strftime("%Y%m%d%H%M%S", time.gmtime())
    log_folder = log_config.args.get("log_folder", "logs")
    run_folder = f"{log_folder}/run_{log_id}"
    logger.add(
        f"{run_folder}/strategy/{stg_name}_{{time}}.log",
        format=simulated_formatter.formatter,
        rotation="100 MB",
        colorize=False,  # File logs should never have colors
        level=QubxLogConfig.get_log_level(),
    )

    _log_writer_name = log_config.logger

    logger.debug(f"Setup <g>{_log_writer_name}</g> logger...")

    override_params = {
        "account_id": "account",
        "strategy_id": stg_name,
        "run_id": run_id,
        "log_folder": run_folder,
    }
    _log_writer_params = {**override_params, **log_config.args}

    _log_writer = create_logs_writer(_log_writer_name, _log_writer_params)
    stg_logging = StrategyLogging(
        logs_writer=_log_writer,
        positions_log_freq=log_config.position_interval,
        portfolio_log_freq=log_config.portfolio_interval,
        heartbeat_freq=log_config.heartbeat_interval,
    )
    return stg_logging


def _create_tcc(exchange_name: str, account_manager: AccountConfigurationManager) -> TransactionCostsCalculator:
    if exchange_name == "BINANCE.PM":
        # TODO: clean this up
        exchange_name = "BINANCE.UM"
    settings = account_manager.get_exchange_settings(exchange_name)
    tcc = lookup.find_fees(exchange_name, settings.commissions)
    assert tcc is not None, f"Can't find fees calculator for {exchange_name} exchange"
    return tcc


def _create_data_provider(
    exchange_name: str,
    exchange_config: ExchangeConfig,
    time_provider: ITimeProvider,
    channel: CtrlChannel,
    account_manager: AccountConfigurationManager,
    health_monitor: IHealthMonitor,
    loop: asyncio.AbstractEventLoop | None = None,
) -> IDataProvider:
    connector_name = exchange_config.connector.lower()

    return ConnectorRegistry.get_data_provider(
        connector_name,
        exchange_name=exchange_name,
        time_provider=time_provider,
        channel=channel,
        health_monitor=health_monitor,
        account_manager=account_manager,
        loop=loop,
        **exchange_config.params,
    )


def _create_account_processor(
    exchange_name: str,
    exchange_config: ExchangeConfig,
    channel: CtrlChannel,
    time_provider: ITimeProvider,
    account_manager: AccountConfigurationManager,
    tcc: TransactionCostsCalculator,
    paper: bool,
    health_monitor: IHealthMonitor,
    live_config: LiveConfig,
    data_provider: IDataProvider | None = None,
    restored_state: RestoredState | None = None,
    read_only: bool = False,
    loop: asyncio.AbstractEventLoop | None = None,
) -> IAccountProcessor:
    # Resolve base_currency with priority: per-exchange YAML > global YAML > accounts.toml
    if exchange_config.base_currency is not None:
        base_currency = exchange_config.base_currency
    elif live_config.base_currency is not None:
        base_currency = live_config.base_currency
    else:
        base_currency = account_manager.get_exchange_settings(exchange_name).base_currency

    if paper:
        # Paper trading: create SimulatedAccountProcessor directly (not registered with registry)
        from qubx.backtester.account import SimulatedAccountProcessor
        from qubx.backtester.simulated_exchange import get_simulated_exchange

        settings = account_manager.get_exchange_settings(exchange_name)
        simulated_exchange = get_simulated_exchange(exchange_name, time_provider, tcc)

        return SimulatedAccountProcessor(
            account_id=exchange_name,
            exchange=simulated_exchange,
            channel=channel,
            health_monitor=health_monitor,
            base_currency=settings.base_currency,
            exchange_name=exchange_name,
            initial_capital=settings.initial_capital,
            restored_state=restored_state,
        )

    if exchange_config.account is not None:
        connector = exchange_config.account.connector
    else:
        connector = exchange_config.connector

    connector_name = connector.lower()

    return ConnectorRegistry.get_account_processor(
        connector_name,
        exchange_name=exchange_name,
        channel=channel,
        time_provider=time_provider,
        account_manager=account_manager,
        tcc=tcc,
        health_monitor=health_monitor,
        data_provider=data_provider,
        restored_state=restored_state,
        read_only=read_only,
        loop=loop,
    )


def _create_broker(
    exchange_name: str,
    exchange_config: ExchangeConfig,
    channel: CtrlChannel,
    time_provider: ITimeProvider,
    account: IAccountProcessor,
    data_provider: IDataProvider,
    account_manager: AccountConfigurationManager,
    health_monitor: IHealthMonitor,
    paper: bool,
    loop: asyncio.AbstractEventLoop | None = None,
) -> IBroker:
    if paper:
        # Paper trading: create SimulatedBroker directly (not registered with registry)
        from qubx.backtester.account import SimulatedAccountProcessor
        from qubx.backtester.broker import SimulatedBroker

        assert isinstance(account, SimulatedAccountProcessor), (
            "Account must be SimulatedAccountProcessor for paper mode"
        )

        return SimulatedBroker(
            channel=channel,
            account=account,
            simulated_exchange=account._exchange,
        )

    if exchange_config.broker is not None:
        connector = exchange_config.broker.connector
        params = dict(exchange_config.broker.params)
    else:
        connector = exchange_config.connector
        params = {}

    connector_name = connector.lower()

    return ConnectorRegistry.get_broker(
        connector_name,
        exchange_name=exchange_name,
        channel=channel,
        time_provider=time_provider,
        account=account,
        data_provider=data_provider,
        account_manager=account_manager,
        health_monitor=health_monitor,
        loop=loop,
        **params,
    )


def _create_instruments_for_exchange(exchange_name: str, exchange_config: ExchangeConfig) -> list[Instrument]:
    exchange_name = exchange_name.upper()
    if exchange_name == "BINANCE.PM":
        # TODO: clean this up
        exchange_name = "BINANCE.UM"
    symbols = exchange_config.universe
    instruments = []
    for symbol in symbols:
        _e, _mt, _s = Instrument.parse_notation(symbol)
        # - use exchange from notation if provided, otherwise use section exchange
        _exch = _e.upper() if _e else exchange_name
        instr = lookup.find_symbol(_exch, _s.upper(), market_type=_mt)
        if instr is not None:
            instruments.append(instr)
    return instruments


def _create_data_throttler(throttling_config):
    """
    Create data throttler from throttling configuration.

    Args:
        throttling_config: ThrottlingConfig object with throttle settings

    Returns:
        InstrumentThrottler configured with per-data-type frequency limits, or None if disabled
    """
    from qubx.utils.throttler import InstrumentThrottler

    if not throttling_config or not throttling_config.enabled:
        return None

    # Build config dict: {data_type: max_frequency_hz}
    throttle_cfg_dict = {}
    for throttle_cfg in throttling_config.throttles:
        if throttle_cfg.enabled:
            throttle_cfg_dict[throttle_cfg.data_type] = throttle_cfg.max_frequency_hz
            logger.info(
                f"Throttling <y>{throttle_cfg.data_type}</y>: max {throttle_cfg.max_frequency_hz} Hz per instrument"
            )

    if not throttle_cfg_dict:
        return None

    return InstrumentThrottler(throttle_cfg_dict)


def _apply_inverse_exchange_mapping(exchanges: list[str]) -> list[str]:
    """
    Apply inverse exchange mapping to the list of exchanges.

    This converts mapped exchanges (like BINANCE.PM) back to their original form (like BINANCE.UM)
    so that SimulationRunner doesn't need to handle EXCHANGE_MAPPINGS.
    """
    mapped_exchanges = []
    for exchange in exchanges:
        if exchange in INVERSE_EXCHANGE_MAPPINGS:
            mapped_exchanges.append(INVERSE_EXCHANGE_MAPPINGS[exchange])
        else:
            mapped_exchanges.append(exchange)
    return mapped_exchanges


def _run_warmup(
    ctx: IStrategyContext,
    restored_state: RestoredState | None,
    exchanges: dict[str, ExchangeConfig],
    warmup: WarmupConfig | None,
    prefetch_config: PrefetchConfig,
    simulated_formatter: SimulatedLogFormatter,
    account_manager: AccountConfigurationManager | None = None,
    enable_funding: bool = False,
    trading_sessions_time: str | None = None,
) -> None:
    """
    Run the warmup period for the strategy.
    """
    if warmup is None:
        return

    initializer = ctx.initializer
    warmup_period = initializer.get_warmup()

    # - find start time for warmup
    if (start_time_finder := initializer.get_start_time_finder()) is None:
        initializer.set_start_time_finder(start_time_finder := TimeFinder.LAST_SIGNAL)

    if initializer.get_state_resolver() is None:
        initializer.set_state_resolver(StateResolver.REDUCE_ONLY)

    current_time = ctx.time()
    warmup_start_time = current_time
    if restored_state is not None:
        warmup_start_time = start_time_finder(current_time, restored_state)
        time_delta = pd.Timedelta(current_time - warmup_start_time)
        if time_delta.total_seconds() > 0:
            logger.info(f"<yellow>Start time finder estimated to go back in time by {time_delta}</yellow>")

    if warmup_period is not None:
        logger.info(f"<yellow>Warmup period is set to {pd.Timedelta(warmup_period)}</yellow>")
        warmup_start_time -= warmup_period

    if warmup_start_time == current_time:
        # if start time is the same as current time, we don't need to run warmup
        return

    logger.info(f"<yellow>Warmup start time: {warmup_start_time}</yellow>")

    # - resolve warmup data sources (mirrors SimulationConfig layout)
    # - warmup.data is required (StorageConfig, not None) so construct_storage always returns IStorage
    _warmup_data_storage = construct_storage(warmup.data)
    assert _warmup_data_storage is not None, f"Failed to construct warmup data storage from: {warmup.data}"
    _warmup_custom_data = create_data_type_storages(warmup.custom_data) if warmup.custom_data else None
    # - use explicitly configured aux storage, otherwise a stub that raises on access
    _warmup_aux_storage = (
        construct_multi_storage(normalize_aux_config(warmup.aux))
        if warmup.aux
        else NoConfiguredStorage("No aux storage configured for warmup — specify 'aux:' in warmup config")
    )

    # - create instruments
    instruments = []
    for exchange_name, exchange_config in exchanges.items():
        instruments.extend(_create_instruments_for_exchange(exchange_name, exchange_config))
    if restored_state is not None:
        instruments.extend(restored_state.instrument_to_signal_positions.keys())

    assert isinstance(ctx.initializer, BasicStrategyInitializer)
    ctx.initializer.simulation = True

    logger.info(f"<yellow>Running warmup from {warmup_start_time} to {current_time}</yellow>")
    warmup_runner = SimulationRunner(
        setup=SimulationSetup(
            setup_type=SetupTypes.STRATEGY,
            name=ctx.strategy_name,
            generator=ctx.strategy,
            tracker=None,
            instruments=instruments,
            # Apply inverse exchange mapping so SimulationRunner doesn't need EXCHANGE_MAPPINGS
            exchanges=_apply_inverse_exchange_mapping(ctx.exchanges),
            capital=ctx.account.get_total_capital(),
            base_currency=ctx.account.get_base_currency(),
            commissions=None,  # TODO: get commissions from somewhere
            enable_funding=enable_funding,
        ),
        data_config=recognize_simulation_data_config(
            data_storage=_warmup_data_storage,
            custom_data=_warmup_custom_data,
            aux_data_storage=_warmup_aux_storage,
            prefetch_config=prefetch_config,
            trading_sessions_time=trading_sessions_time,
        ),
        start=cast(pd.Timestamp, pd.Timestamp(warmup_start_time)),
        stop=cast(pd.Timestamp, pd.Timestamp(current_time)),
        emitter=ctx.emitter,
        notifier=ctx.notifier,
        strategy_state=ctx._strategy_state,
        initializer=ctx.initializer,
        warmup_mode=True,
    )

    # - set the time provider to the simulated runner
    _live_time_provider = simulated_formatter.time_provider
    simulated_formatter.time_provider = warmup_runner.ctx

    ctx._strategy_state.is_warmup_in_progress = True

    try:
        warmup_runner.run(catch_keyboard_interrupt=False, close_data_readers=True)
    finally:
        # Restore the live time provider
        simulated_formatter.time_provider = _live_time_provider
        # Set back the context for metric emitters to use live context
        if ctx.emitter is not None:
            ctx.emitter.set_context(ctx)
        ctx._strategy_state.is_warmup_in_progress = False
        ctx.initializer.simulation = False

    logger.info("<yellow>Warmup completed</yellow>")

    # - reset the strategy ctx to point back to live context
    if hasattr(ctx.strategy, "ctx"):
        setattr(ctx.strategy, "ctx", ctx)

    # - create a restored state based on warmup runner context
    warmup_account = warmup_runner.ctx.account

    # - get the instruments from the warmup runner context
    _instruments = warmup_runner.ctx.instruments
    _positions = warmup_account.get_positions()
    _positions = {k: v for k, v in _positions.items() if k in _instruments and v is not None and v.quantity is not None}
    _orders = warmup_account.get_orders()
    instrument_to_orders = defaultdict(list)
    for o in _orders.values():
        if o.instrument in _instruments:
            instrument_to_orders[o.instrument].append(o)

    # - find instruments with nonzero positions from restored state and add them to the context
    if restored_state is not None:
        restored_positions = {k: p for k, p in restored_state.positions.items() if p.is_open()}
        # - if there is no warmup position for a restored position, then create a new zero position
        for pos in restored_positions.values():
            if pos.instrument not in _positions:
                _positions[pos.instrument] = Position(pos.instrument)

    # - set the warmup positions and orders
    ctx.set_warmup_positions(_positions)
    ctx.set_warmup_orders(instrument_to_orders)
    ctx.set_warmup_active_targets(warmup_runner.ctx.get_active_targets())

    # - subscribe to new subscriptions that could have been added during warmup
    live_subscriptions = ctx.get_subscriptions()
    warmup_subscriptions = warmup_runner.ctx.get_subscriptions()
    new_subscriptions = set(warmup_subscriptions) - set(live_subscriptions)

    for sub in new_subscriptions:
        ctx.subscribe(sub)

    # - update cache in the original context (only for instruments with positions)
    live_cache = ctx.get_market_data_cache()
    warmup_cache = warmup_runner.ctx.get_market_data_cache()
    live_cache.set_state_from(warmup_cache, instruments=list(_positions.keys()))


def _apply_base_live_subscription(ctx: IStrategyContext) -> None:
    """Apply base_live_subscription if it differs from the base subscription."""
    base_subscription = ctx.initializer.get_base_subscription()
    base_live_subscription = ctx.initializer.get_base_live_subscription()
    if base_live_subscription is not None and base_live_subscription != base_subscription:
        logger.info(f"Setting base live subscription from {base_subscription} to {base_live_subscription}")
        ctx.set_base_subscription(base_live_subscription)
        ctx.subscribe(base_live_subscription)


def simulate_strategy(
    config_file: Path,
    save_path: str | None = None,
    start: str | None = None,
    stop: str | None = None,
    report: str | None = None,
    log_to_file: bool = False,
    storage_options: dict | None = None,
    name: str | None = None,
):
    """
    Simulate a strategy.

    Args:
        config_file: Path to the strategy configuration file
        save_path: Path to save the simulation results. Always uses parquet-based storage.
                   Supports local paths and cloud URIs (s3://, gs://, az://).
        start: Start time for the simulation
        stop: Stop time for the simulation
        report: Ignored (kept for backward compatibility — parquet storage has no separate report)
        log_to_file: If True, writes all simulation logs to a .log file alongside results
                     (local paths only — ignored for cloud URIs)
        storage_options: Cloud storage credentials dict. None = uses default_s3_account from settings.
        name: Override the run name used for output folder construction. When provided, takes
              precedence over the 'name:' field in the config file. Useful for distinguishing
              runs (e.g. 'smoketest', '01_reference') without editing the config.
    """
    if not config_file.exists():
        raise FileNotFoundError(f"Configuration file for simualtion not found: {config_file}")

    cfg = load_strategy_config_from_yaml(config_file)

    # Load plugins from configuration
    from qubx.plugins import load_plugins

    load_plugins(cfg.plugins)

    if cfg.simulation is None:
        raise ValueError("Simulation configuration is required")

    if cfg.simulation.run_separate_instruments and cfg.simulation.variate:
        raise ValueError("Run separate instruments is not supported with variate")

    stg = cfg.strategy
    simulation_name = config_file.stem  # - used for config_name metadata field and log paths
    _v_id = cast(pd.Timestamp, pd.Timestamp("now")).strftime("%Y%m%d_%H%M%S")

    # - resolve run name: CLI --name > config 'name:' field > config filename stem
    # - {base_path}/{_yaml_name}/{ShortClass}/{timestamp}/
    _yaml_name = name or cfg.name or simulation_name
    if name:
        logger.info(f"Run name overridden via CLI: <g>{name}</g>")

    match stg:
        case list():
            stg_cls = reduce(lambda x, y: x + y, [class_import(x) for x in stg])
            _strategy_full_classes = stg
        case str():
            stg_cls = class_import(stg)
            _strategy_full_classes = [stg]
        case _:
            raise SimulationConfigError(f"Invalid strategy type: {stg}")

    # - create simulation setup
    if cfg.simulation.variate:
        # - get conditions for variations if exists
        cond = cfg.simulation.variate.pop("with", None)
        conditions = []
        dict2lambda = lambda a, d: eval(f"lambda {a}: {d}")  # noqa: E731
        if cond:
            for a, c in cond.items():
                conditions.append(dict2lambda(a, c))

        # - if a parameter is of type list, then transform it to list of lists to avoid invalid variation
        for k, v in cfg.parameters.items():
            if isinstance(v, list):
                cfg.parameters[k] = [v]

        experiments = variate(stg_cls, **(cfg.parameters | cfg.simulation.variate), conditions=conditions)
        # - use _yaml_name so TradingSessionResult.name matches storage path
        experiments = {f"{_yaml_name}.{_v_id}.[{k}]": v for k, v in experiments.items()}
        print(f"Parameters variation is configured. There are {len(experiments)} simulations to run.")
        _n_jobs = -1
    else:
        strategy = stg_cls(**cfg.parameters)
        # - use _yaml_name so TradingSessionResult.name matches storage path
        experiments = {_yaml_name: strategy}
        _n_jobs = 1

    # - resolve main data storage (data used for simulation)
    data = construct_storage(cfg.simulation.data)

    # - resolve custom data storage if configured
    data_i = create_data_type_storages(cfg.simulation.custom_data) if cfg.simulation.custom_data else {}

    sim_params = {
        "instruments": cfg.simulation.instruments,
        "capital": cfg.simulation.capital,
        "commissions": cfg.simulation.commissions,
        "start": cfg.simulation.start,
        "stop": cfg.simulation.stop,
        "enable_funding": cfg.simulation.enable_funding,
        "enable_inmemory_emitter": cfg.simulation.enable_inmemory_emitter,
    }

    if cfg.simulation.base_currency is not None:
        sim_params["base_currency"] = cfg.simulation.base_currency

    if cfg.simulation.debug is not None:
        sim_params["debug"] = cfg.simulation.debug

    if cfg.simulation.portfolio_log_freq is not None:
        sim_params["portfolio_log_freq"] = cfg.simulation.portfolio_log_freq

    if start is not None:
        sim_params["start"] = start
        logger.info(f"Start date set to {start}")

    if stop is not None:
        sim_params["stop"] = stop
        logger.info(f"Stop date set to {stop}")

    if cfg.simulation.n_jobs is not None:
        sim_params["n_jobs"] = cfg.simulation.n_jobs

    # - check for aux_data parameter
    aux_configs = resolve_aux_config(cfg.aux, getattr(cfg.simulation, "aux", None))
    if aux_configs:
        sim_params["aux_data"] = construct_multi_storage(aux_configs)

    # - add run_separate_instruments parameter
    if cfg.simulation.run_separate_instruments:
        sim_params["run_separate_instruments"] = True

    # - add prefetch_config parameter
    if cfg.simulation.prefetch is not None:
        sim_params["prefetch_config"] = cfg.simulation.prefetch

    # - trading sessions config
    if cfg.simulation.trading_session is not None:
        sim_params["trading_sessions_time"] = cfg.simulation.trading_session

    # - normalize description and tags from config
    _descr: str = ""
    if cfg.description is not None:
        _descr = "\n".join(cfg.description) if isinstance(cfg.description, list) else str(cfg.description)

    _tags = normalize_tags(cfg.tags)

    # - always use parquet-based storage; S3 creds resolved inside SimulationResultsSaver
    _use_storage = save_path is not None

    # - run simulation
    print(f" > Run simulation for [{red(simulation_name)}] ::: {sim_params['start']} - {sim_params['stop']}")
    sim_params["n_jobs"] = sim_params.get("n_jobs", _n_jobs)

    # - create simulation results saver first (needed for run_dir to set up local log path)
    _results_saver: SimulationResultsSaver | None = None
    if _use_storage:
        _results_saver = SimulationResultsSaver(
            name=_yaml_name,
            strategy_class=_strategy_full_classes,
            config_name=simulation_name,
            sim_start=cfg.simulation.start,
            sim_stop=cfg.simulation.stop,
            save_path=save_path,
            run_id=_v_id,
            config_file=str(config_file),
            tags=_tags,
            description=_descr,
            is_variation=bool(cfg.simulation.variate),
            storage_options=storage_options,
        )
        _results_saver.write_pending()

    # - resolve log file path (after saver so we can use run_dir for the local case)
    _log_file: str | None = None
    _cloud_log_file: str | None = None  # - set only for cloud; uploaded by saver, deleted on failure
    if log_to_file:
        _is_cloud = save_path is not None and is_cloud_path(save_path)

        if _is_cloud:
            import tempfile

            _tmp = tempfile.NamedTemporaryFile(suffix=".log", delete=False)
            _tmp.close()
            _log_file = _cloud_log_file = _tmp.name
            print(f" > Logging to temp file (will upload to {green(save_path)} after simulation) ...")
        elif _results_saver is not None:
            # - local with storage: write directly into run_dir (already the right destination)
            makedirs(_results_saver.run_dir)
            _log_file = str(Path(_results_saver.run_dir) / f"{simulation_name}.log")
            print(f" > Logging to file {green(_log_file)} ...")
        else:
            # - local without storage: fall back to results/simulation_name/
            _log_dir = Path(makedirs("results/")) / simulation_name
            makedirs(str(_log_dir))
            _log_file = str(_log_dir / f"{simulation_name}.log")
            print(f" > Logging to file {green(_log_file)} ...")

    _t_sim_start = time.monotonic()
    try:
        test_res = simulate(experiments, data=data, custom_data=data_i, log_file=_log_file, **sim_params)  # type: ignore
    except Exception as e:
        if _results_saver is not None:
            _results_saver.write_failed(e, log_file=_cloud_log_file)
        raise

    _sim_time_sec = int(round(time.monotonic() - _t_sim_start))

    # - attach simulation wall-clock time (raw seconds) to every result
    for _r in test_res:
        _r.simulation_time_sec = _sim_time_sec

    if _results_saver is not None:
        # - for cloud: pass temp log file so saver uploads it; for local it's already in run_dir
        _results_saver.store_simulation_results(test_res, _sim_time_sec, log_file=_cloud_log_file)
    elif _use_storage is False and len(test_res) > 0:
        # - no saver (no output path) — just log timing
        print(f" > Simulation finished in {green(convert_seconds_to_str(_sim_time_sec))}")

    return test_res
