//! BacktestHarness — orchestrator wiring ml-alpha's MultiHorizonLoader
//! and PerceptionTrainer (in inference role) into the LobSimCuda.
//!
//! Design: the harness reuses the trainer's loader + forward chain
//! (PerceptionTrainer::evaluate_batched, accessed via `forward_only`)
//! so train-vs-deploy skew is structurally impossible. The input struct
//! (`Mbp10RawInput`), feature-assembly cubin, and full perception
//! forward are the same code paths used in training. The harness's
//! only job is to walk the chronological snapshot stream, maintain a
//! sliding K-window for the recurrent Mamba2 context, drive the sim's
//! decision loop with the trainer's per-horizon probability output.

use anyhow::{Context, Result};
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::loader::{
    discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::PerceptionTrainer;
use ml_core::device::MlDevice;
use std::collections::VecDeque;
use std::path::PathBuf;

use crate::sim::LobSimCuda;

#[derive(Clone, Debug)]
pub struct BacktestHarnessConfig {
    pub data_root: PathBuf,
    pub predecoded_dir: PathBuf,
    pub n_parallel: usize,
    pub decision_stride: usize,
    pub target_annual_vol_units: f32,
    pub annualisation_factor: f32,
    pub max_lots: u16,
    /// Max number of inference inputs to consume. 0 = exhaust loader.
    pub max_events: u64,
    /// Per-backtest Strategy compositions. Empty = every cell uses the
    /// hardcoded default policy (WeightedByRealizedSharpe across all 5
    /// horizons). Non-empty: len MUST equal n_parallel; each entry is
    /// flattened to bytecode and uploaded to its backtest slot.
    pub strategies: Vec<crate::policy::Strategy>,
    /// Total submission→fill latency in nanoseconds. 0 = immediate
    /// (legacy path). 100_000_000 = 100ms IBKR + Scaleway baseline.
    /// When non-zero, the orchestrator submits aggressive IOC limits
    /// with `active=2`/`arrival_ts_ns = current + latency_ns` so the
    /// book can move under them during the in-flight window.
    pub latency_ns: u32,
    /// Cold-start Kelly fraction floor — used when isv_kelly is at
    /// sentinel (no closed trades yet). Per
    /// pearl_blend_formulas_must_have_permanent_floor: max(floor, real),
    /// not blend. Default 0.10 = trade conservatively until Kelly state
    /// bootstraps.
    pub kelly_frac_floor: f32,
    /// Cold-start Sharpe weight floor — applied to recent_sharpe when
    /// aggregating signed-sizes across horizons. Lets the cross-horizon
    /// sum produce a non-zero size before recent_sharpe is populated.
    /// Default 0.10 = uniform 1/5 weight per horizon at cold-start.
    pub sharpe_weight_floor: f32,
    /// P4: threshold gate — absolute conviction cutoff. When
    /// `max_h |alpha[h] - 0.5| * 2 < threshold`, the decision kernels
    /// emit noop. Default 0.0 = gate disabled (every signal passes).
    pub threshold: f32,
    /// P4: per-fill cost (price-units / lot / side). Deducted from
    /// pos.realized_pnl and accumulated into total_fees_per_b_d at
    /// every fill. Default 0.0 = no cost (frictionless).
    pub cost_per_lot_per_side: f32,
    /// P6: optional per-backtest variant names. When Some, write_artifacts
    /// writes subdirs as `sim_<name>` instead of `cell_NNNN`. Length MUST
    /// equal n_parallel when set. None preserves the legacy cell_NNNN
    /// naming for non-grid-pack callers (smoke, fixtures).
    pub variant_names: Option<Vec<String>>,
    /// P6: optional pre-built BatchedSimConfig (one entry per backtest =
    /// per variant). When Some, the harness uses this directly instead
    /// of building from_uniform off the scalar fields above. Length MUST
    /// equal n_parallel when set.
    pub sim_config_override: Option<crate::sim::BatchedSimConfig>,
}

pub struct BacktestHarness {
    cfg: BacktestHarnessConfig,
    /// Per-backtest sim parameter arrays. P1 onwards is the source of
    /// truth for sim-side scalar params; cfg's scalar fields are still
    /// used to construct this via BatchedSimConfig::from_uniform at
    /// harness new(), but the run-loop reads sim_config, not cfg.
    sim_config: crate::sim::BatchedSimConfig,
    loader: MultiHorizonLoader,
    /// PerceptionTrainer in inference role — owns the trunk (loaded from
    /// Checkpoint) and the kernel-launch scratches. Forward driven via
    /// `forward_only` at decision-stride boundaries.
    trainer: PerceptionTrainer,
    /// Sliding K-window of recent snapshots for the recurrent forward.
    /// At every decision-stride boundary, when the window has reached
    /// `seq_len` entries, we call `trainer.forward_only(&window)` and
    /// take the last K position's probs.
    snapshot_window: VecDeque<Mbp10RawInput>,
    /// Window capacity = trainer's seq_len, captured at construction.
    seq_len: usize,
    sim: LobSimCuda,
    decision_count: u64,
    event_count: u64,
    /// Per-cell cumulative P&L curve in USD, sampled once per event.
    /// `pnl_curves[b][i]` = realized_pnl USD for backtest b after event i.
    pnl_curves: Vec<Vec<f32>>,
}

impl BacktestHarness {
    /// Construct with an externally-built MlDevice + PerceptionTrainer.
    /// Caller loads the trainer from a Checkpoint via
    /// `PerceptionTrainer::from_checkpoint`. The harness reads
    /// `trainer.config().seq_len` to size its sliding snapshot window.
    pub fn new(
        cfg: BacktestHarnessConfig,
        dev: &MlDevice,
        trainer: PerceptionTrainer,
    ) -> Result<Self> {
        if !cfg.strategies.is_empty() {
            anyhow::ensure!(
                cfg.strategies.len() == cfg.n_parallel,
                "strategies len {} ≠ n_parallel {}",
                cfg.strategies.len(),
                cfg.n_parallel
            );
        }
        let files = discover_mbp10_files_sorted(&cfg.data_root)?;
        let loader_cfg = MultiHorizonLoaderConfig {
            files,
            predecoded_dir: cfg.predecoded_dir.clone(),
            seq_len: 1,
            horizons: [30, 100, 300, 1000, 6000],
            n_max_sequences: 0,
            seed: 0,
            decision_stride: cfg.decision_stride,
            inference_only: true,
        };
        let loader = MultiHorizonLoader::new(&loader_cfg)?;

        let seq_len = trainer.config().seq_len;
        anyhow::ensure!(
            trainer.config().n_batch == 1,
            "BacktestHarness requires PerceptionTrainer with n_batch=1 (got {})",
            trainer.config().n_batch
        );

        let mut sim = LobSimCuda::new(cfg.n_parallel, dev)?;

        // Upload per-cell bytecode programs if the caller provided any.
        // Empty Vec means every cell uses the hardcoded default policy.
        for (b, strat) in cfg.strategies.iter().enumerate() {
            let prog = strat.flatten();
            sim.upload_program(b, &prog)?;
        }

        // P6: if sim_config_override is provided (sweep runner grid-pack
        // flow), use it directly; otherwise build a uniform broadcast from
        // scalar cfg fields (smoke + fixture flows).
        let sim_config = match cfg.sim_config_override.clone() {
            Some(bc) => {
                anyhow::ensure!(
                    bc.n_backtests() == cfg.n_parallel,
                    "sim_config_override has {} variants but n_parallel = {}",
                    bc.n_backtests(), cfg.n_parallel,
                );
                bc
            }
            None => crate::sim::BatchedSimConfig::from_uniform(
                cfg.n_parallel,
                &crate::sim::UniformSimParams {
                    target_annual_vol_units: cfg.target_annual_vol_units,
                    annualisation_factor:    cfg.annualisation_factor,
                    max_lots:                cfg.max_lots,
                    latency_ns:              cfg.latency_ns,
                    kelly_frac_floor:        cfg.kelly_frac_floor,
                    sharpe_weight_floor:     cfg.sharpe_weight_floor,
                    threshold:               cfg.threshold,
                    cost_per_lot_per_side:   cfg.cost_per_lot_per_side,
                },
            ),
        };

        let pnl_curves = (0..cfg.n_parallel).map(|_| Vec::with_capacity(1024)).collect();
        Ok(Self {
            cfg,
            sim_config,
            loader,
            trainer,
            snapshot_window: VecDeque::with_capacity(seq_len),
            seq_len,
            sim,
            decision_count: 0,
            event_count: 0,
            pnl_curves,
        })
    }

    /// Drive the chronological event stream → trunk inference → sim
    /// decision loop until either the stream is exhausted or
    /// cfg.max_events is reached. See spec §7 orchestrator code.
    pub fn run(&mut self) -> Result<RunStats> {
        let stride = self.cfg.decision_stride.max(1) as u64;
        let mut total_decisions = 0u64;
        // Periodic progress line — without this, multi-million-event
        // runs look identical to a deadlock in pod logs.
        const PROGRESS_EVERY: u64 = 1_000_000;
        let mut next_progress_event = PROGRESS_EVERY;
        let started = std::time::Instant::now();

        while let Some(raw) = self.loader.next_inference_input()? {
            // Apply snapshot to every backtest's book.
            self.sim.apply_snapshot(&raw.bid_px, &raw.bid_sz, &raw.ask_px, &raw.ask_sz)?;

            // Process resting orders (in-flight promotion, queue decay,
            // marketability check, stop triggers, OCO). Trade-flow signal
            // comes from the loader's L1-delta tick-rule inference (see
            // ml_alpha::data::loader::infer_signed_trade_flow). Positive =
            // buyer-initiated, negative = seller-initiated.
            self.sim.step_resting_orders(raw.ts_ns, raw.trade_signed_vol)?;

            // Maintain the sliding K-window for the recurrent forward.
            // Push the current snapshot, evict the oldest if at capacity.
            if self.snapshot_window.len() == self.seq_len {
                self.snapshot_window.pop_front();
            }
            self.snapshot_window.push_back(raw.clone());

            // At decision-stride boundaries: run forward inference + sim
            // decision. Skip until the window is full (insufficient
            // context for Mamba2's recurrent state).
            if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len {
                let window: Vec<Mbp10RawInput> = self.snapshot_window.iter().cloned().collect();
                let probs_all = self.trainer.forward_only(&window)
                    .context("trainer.forward_only")?;
                // probs_all is [K * B * N_HORIZONS] with B=1; take the
                // LAST K position's probs as the decision signal.
                let last_probs_start = (self.seq_len - 1) * N_HORIZONS;
                let last_probs: [f32; N_HORIZONS] = probs_all[last_probs_start..]
                    .try_into()
                    .context("slice last K probs")?;
                self.sim.broadcast_alpha(&last_probs)?;
                self.sim.step_decision_with_latency(raw.ts_ns, &self.sim_config)?;
                self.decision_count += 1;
                total_decisions += 1;
            }

            // Sample per-cell realised P&L (price-units → USD via $50/index-pt).
            for b in 0..self.cfg.n_parallel {
                let pos = self.sim.read_pos(b)?;
                self.pnl_curves[b].push(pos.realized_pnl * 50.0);
            }

            self.event_count += 1;
            if self.event_count >= next_progress_event {
                let elapsed = started.elapsed().as_secs_f32();
                let rate = self.event_count as f32 / elapsed.max(1e-3);
                eprintln!(
                    "progress: events={} decisions={} elapsed={:.0}s rate={:.0}ev/s",
                    self.event_count, total_decisions, elapsed, rate,
                );
                next_progress_event += PROGRESS_EVERY;
            }
            if self.cfg.max_events > 0 && self.event_count >= self.cfg.max_events {
                break;
            }
        }

        Ok(RunStats {
            events_processed: self.event_count,
            decisions_taken: total_decisions,
        })
    }

    /// After `run()` completes, write per-cell artifacts to
    /// `<out_dir>/cell_<b>/{summary.json,trades.csv,pnl_curve.bin}`.
    pub fn write_artifacts(&self, out_dir: &std::path::Path) -> Result<()> {
        std::fs::create_dir_all(out_dir)
            .with_context(|| format!("create out dir {}", out_dir.display()))?;
        if let Some(names) = self.cfg.variant_names.as_ref() {
            anyhow::ensure!(
                names.len() == self.cfg.n_parallel,
                "variant_names len {} != n_parallel {}",
                names.len(), self.cfg.n_parallel,
            );
        }
        for b in 0..self.cfg.n_parallel {
            // P6: when sweep runner provides variant names, dir = sim_<name>
            // per spec §3.3. Otherwise fall back to legacy cell_NNNN naming.
            let subdir_name = match self.cfg.variant_names.as_ref() {
                Some(names) => format!("sim_{}", names[b]),
                None        => format!("cell_{b:04}"),
            };
            let cell_dir = out_dir.join(subdir_name);
            std::fs::create_dir_all(&cell_dir)
                .with_context(|| format!("create cell dir {}", cell_dir.display()))?;
            let records = self.sim.read_trade_records(b)?;
            let curve = &self.pnl_curves[b];
            let summary = crate::artifacts::compute_summary(&records, curve);
            crate::artifacts::write_summary(&cell_dir.join("summary.json"), &summary)?;
            crate::artifacts::write_trades_csv(&cell_dir.join("trades.csv"), &records)?;
            crate::artifacts::write_pnl_curve_bin(&cell_dir.join("pnl_curve.bin"), curve)?;
        }
        Ok(())
    }

    pub fn sim(&self) -> &LobSimCuda { &self.sim }
    pub fn event_count(&self) -> u64 { self.event_count }
    pub fn decision_count(&self) -> u64 { self.decision_count }
}

#[derive(Clone, Copy, Debug)]
pub struct RunStats {
    pub events_processed: u64,
    pub decisions_taken: u64,
}
