use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, NaiveDate, Utc};
use native_tls::TlsConnector;
use postgres_native_tls::MakeTlsConnector;
use serde::Serialize;
use tokio_postgres::{Client, NoTls};
use tracing::warn;
use uforia_ms_types::{
    Asset, FundingEvent, FundingRate, IvEvent, L2Snapshot, L2Update, MarketEvent, Side, Trade,
    Venue,
};

use crate::{BookRuntimeState, EngineHealth};

#[derive(Debug, Clone)]
pub struct DbSinkConfig {
    pub dsn: String,
    pub schema: String,
    pub buffer_size: usize,
}

#[derive(Debug, Serialize)]
struct RawEventRow {
    ts: DateTime<Utc>,
    date: NaiveDate,
    asset: String,
    venue: String,
    event_type: String,
    event_id: String,
    exchange_symbol: String,
    venue_group: String,
    market_type: String,
    is_derivatives: bool,
    contract_type: String,
    exchange_ts: DateTime<Utc>,
    received_ts: DateTime<Utc>,
    local_ts: DateTime<Utc>,
    transaction_ts: DateTime<Utc>,
    side: Option<String>,
    liquidation_side: Option<String>,
    price: Option<f64>,
    size: Option<f64>,
    trade_notional: Option<f64>,
    bid: Option<f64>,
    ask: Option<f64>,
    mark_price: Option<f64>,
    open_interest: Option<f64>,
    depth_notional_bid_5: Option<f64>,
    depth_notional_ask_5: Option<f64>,
    first_update_id: Option<u64>,
    last_update_id: Option<u64>,
    payload: String,
    quality_flag: String,
    logical_ts: DateTime<Utc>,
    run_id: String,
    run_ts: DateTime<Utc>,
    write_mode: String,
}

#[derive(Debug, Serialize)]
struct FundingSnapshotRow {
    ts: DateTime<Utc>,
    date: NaiveDate,
    venue: String,
    underlying: String,
    source: String,
    interest_1h: Option<f64>,
    interest_8h: Option<f64>,
    quality_flag: String,
    logical_ts: DateTime<Utc>,
    run_id: String,
    run_ts: DateTime<Utc>,
    write_mode: String,
}

#[derive(Debug, Serialize)]
struct BookStateRow {
    ts: DateTime<Utc>,
    date: NaiveDate,
    asset: String,
    venue: String,
    exchange_symbol: String,
    venue_group: String,
    market_type: String,
    is_derivatives: bool,
    contract_type: String,
    best_bid: Option<f64>,
    best_ask: Option<f64>,
    best_bid_size: Option<f64>,
    best_ask_size: Option<f64>,
    mid_price: Option<f64>,
    microprice: Option<f64>,
    spread: Option<f64>,
    spread_rel: Option<f64>,
    depth_bid_5: f64,
    depth_ask_5: f64,
    depth_notional_bid_5: Option<f64>,
    depth_notional_ask_5: Option<f64>,
    imbalance_5: f64,
    is_seeded: bool,
    is_sequence_valid: bool,
    is_gap_detected: bool,
    reseed_count: i64,
    book_quality: String,
    quality_flag: String,
    logical_ts: DateTime<Utc>,
    run_id: String,
    run_ts: DateTime<Utc>,
    write_mode: String,
}

pub struct PostgresSink {
    client: Client,
    schema: String,
    buffer_size: usize,
    raw_events: Vec<RawEventRow>,
    funding_rows: Vec<FundingSnapshotRow>,
    book_rows: Vec<BookStateRow>,
    run_id: String,
    run_ts: DateTime<Utc>,
}

impl PostgresSink {
    pub async fn connect(config: &DbSinkConfig) -> Result<Self> {
        if config.dsn.trim().is_empty() {
            return Err(anyhow!("UFORIA_DB_DSN is required for the microstructure live engine"));
        }
        let client = connect_postgres(&config.dsn, "microstructure postgres sink").await?;
        Ok(Self {
            client,
            schema: config.schema.clone(),
            buffer_size: config.buffer_size.max(1),
            raw_events: Vec::new(),
            funding_rows: Vec::new(),
            book_rows: Vec::new(),
            run_id: uuid::Uuid::new_v4().to_string(),
            run_ts: Utc::now(),
        })
    }

    pub async fn push_market_event(
        &mut self,
        event: &MarketEvent,
        received_at: DateTime<Utc>,
    ) -> Result<()> {
        if let Some(row) = raw_event_row(event, received_at, &self.run_id, self.run_ts)? {
            self.raw_events.push(row);
            if self.raw_events.len() >= self.buffer_size {
                self.flush_raw_events().await?;
            }
        }
        Ok(())
    }

    pub async fn push_funding_event(&mut self, event: &FundingEvent) -> Result<()> {
        if let Some(row) = funding_row(event, &self.run_id, self.run_ts) {
            self.funding_rows.push(row);
            if self.funding_rows.len() >= self.buffer_size {
                self.flush_funding_rows().await?;
            }
        }
        Ok(())
    }

    pub async fn push_iv_event(&mut self, _event: &IvEvent) -> Result<()> {
        Ok(())
    }

    pub async fn push_book_state(
        &mut self,
        timestamp: DateTime<Utc>,
        asset: Asset,
        venue: Venue,
        state: &BookRuntimeState,
    ) -> Result<()> {
        self.book_rows
            .push(book_state_row(timestamp, asset, venue, state, &self.run_id, self.run_ts));
        if self.book_rows.len() >= self.buffer_size {
            self.flush_book_rows().await?;
        }
        Ok(())
    }

    pub async fn flush(&mut self) -> Result<()> {
        self.flush_raw_events().await?;
        self.flush_funding_rows().await?;
        self.flush_book_rows().await?;
        Ok(())
    }

    async fn flush_raw_events(&mut self) -> Result<()> {
        if self.raw_events.is_empty() {
            return Ok(());
        }
        let payload = serde_json::to_value(&self.raw_events)?;
        let sql = format!(
            r#"
            WITH data AS (
                SELECT *
                FROM jsonb_to_recordset($1::jsonb) AS x(
                    ts timestamptz,
                    date date,
                    asset text,
                    venue text,
                    event_type text,
                    event_id text,
                    exchange_symbol text,
                    venue_group text,
                    market_type text,
                    is_derivatives boolean,
                    contract_type text,
                    exchange_ts timestamptz,
                    received_ts timestamptz,
                    local_ts timestamptz,
                    transaction_ts timestamptz,
                    side text,
                    liquidation_side text,
                    price double precision,
                    size double precision,
                    trade_notional double precision,
                    bid double precision,
                    ask double precision,
                    mark_price double precision,
                    open_interest double precision,
                    depth_notional_bid_5 double precision,
                    depth_notional_ask_5 double precision,
                    first_update_id numeric(20,0),
                    last_update_id numeric(20,0),
                    payload text,
                    quality_flag text,
                    logical_ts timestamptz,
                    run_id text,
                    run_ts timestamptz,
                    write_mode text
                )
            )
            INSERT INTO {schema}.microstructure_raw_event (
                ts, date, asset, venue, event_type, event_id, exchange_symbol, venue_group,
                market_type, is_derivatives, contract_type, exchange_ts, received_ts, local_ts,
                transaction_ts, side, liquidation_side, price, size, trade_notional, bid, ask,
                mark_price, open_interest, depth_notional_bid_5, depth_notional_ask_5,
                first_update_id, last_update_id, payload, quality_flag, logical_ts, run_id,
                run_ts, write_mode
            )
            SELECT
                ts, date, asset, venue, event_type, event_id, exchange_symbol, venue_group,
                market_type, is_derivatives, contract_type, exchange_ts, received_ts, local_ts,
                transaction_ts, side, liquidation_side, price, size, trade_notional, bid, ask,
                mark_price, open_interest, depth_notional_bid_5, depth_notional_ask_5,
                first_update_id, last_update_id, payload, quality_flag, logical_ts, run_id,
                run_ts, write_mode
            FROM data
            ON CONFLICT (ts, asset, venue, event_type, event_id, run_ts) DO NOTHING
            "#,
            schema = quoted_schema(&self.schema),
        );
        self.client.execute(sql.as_str(), &[&payload]).await?;
        self.raw_events.clear();
        Ok(())
    }

    async fn flush_funding_rows(&mut self) -> Result<()> {
        if self.funding_rows.is_empty() {
            return Ok(());
        }
        let payload = serde_json::to_value(&self.funding_rows)?;
        let sql = format!(
            r#"
            WITH data AS (
                SELECT *
                FROM jsonb_to_recordset($1::jsonb) AS x(
                    ts timestamptz,
                    date date,
                    venue text,
                    underlying text,
                    source text,
                    interest_1h double precision,
                    interest_8h double precision,
                    quality_flag text,
                    logical_ts timestamptz,
                    run_id text,
                    run_ts timestamptz,
                    write_mode text
                )
            )
            INSERT INTO {schema}.funding_rate_snapshot (
                ts, date, venue, underlying, source, interest_1h, interest_8h, quality_flag,
                logical_ts, run_id, run_ts, write_mode
            )
            SELECT
                ts, date, venue, underlying, source, interest_1h, interest_8h, quality_flag,
                logical_ts, run_id, run_ts, write_mode
            FROM data
            ON CONFLICT (ts, source, run_ts) DO NOTHING
            "#,
            schema = quoted_schema(&self.schema),
        );
        self.client.execute(sql.as_str(), &[&payload]).await?;
        self.funding_rows.clear();
        Ok(())
    }

    async fn flush_book_rows(&mut self) -> Result<()> {
        if self.book_rows.is_empty() {
            return Ok(());
        }
        let payload = serde_json::to_value(&self.book_rows)?;
        let sql = format!(
            r#"
            WITH data AS (
                SELECT *
                FROM jsonb_to_recordset($1::jsonb) AS x(
                    ts timestamptz,
                    date date,
                    asset text,
                    venue text,
                    exchange_symbol text,
                    venue_group text,
                    market_type text,
                    is_derivatives boolean,
                    contract_type text,
                    best_bid double precision,
                    best_ask double precision,
                    best_bid_size double precision,
                    best_ask_size double precision,
                    mid_price double precision,
                    microprice double precision,
                    spread double precision,
                    spread_rel double precision,
                    depth_bid_5 double precision,
                    depth_ask_5 double precision,
                    depth_notional_bid_5 double precision,
                    depth_notional_ask_5 double precision,
                    imbalance_5 double precision,
                    is_seeded boolean,
                    is_sequence_valid boolean,
                    is_gap_detected boolean,
                    reseed_count bigint,
                    book_quality text,
                    quality_flag text,
                    logical_ts timestamptz,
                    run_id text,
                    run_ts timestamptz,
                    write_mode text
                )
            )
            INSERT INTO {schema}.microstructure_book_state (
                ts, date, asset, venue, exchange_symbol, venue_group, market_type,
                is_derivatives, contract_type, best_bid, best_ask, best_bid_size, best_ask_size,
                mid_price, microprice, spread, spread_rel, depth_bid_5, depth_ask_5,
                depth_notional_bid_5, depth_notional_ask_5, imbalance_5, is_seeded,
                is_sequence_valid, is_gap_detected, reseed_count, book_quality, quality_flag,
                logical_ts, run_id, run_ts, write_mode
            )
            SELECT
                ts, date, asset, venue, exchange_symbol, venue_group, market_type,
                is_derivatives, contract_type, best_bid, best_ask, best_bid_size, best_ask_size,
                mid_price, microprice, spread, spread_rel, depth_bid_5, depth_ask_5,
                depth_notional_bid_5, depth_notional_ask_5, imbalance_5, is_seeded,
                is_sequence_valid, is_gap_detected, reseed_count, book_quality, quality_flag,
                logical_ts, run_id, run_ts, write_mode
            FROM data
            ON CONFLICT (ts, asset, venue, run_ts) DO NOTHING
            "#,
            schema = quoted_schema(&self.schema),
        );
        self.client.execute(sql.as_str(), &[&payload]).await?;
        self.book_rows.clear();
        Ok(())
    }
}

pub async fn load_health(
    config: &DbSinkConfig,
    engine: &str,
    mode: &str,
    assets: &[String],
) -> Result<EngineHealth> {
    let client = connect_postgres(&config.dsn, "microstructure health connection").await?;
    let schema = quoted_schema(&config.schema);
    let market_ts = latest_ts(&client, &schema, "microstructure_raw_event").await?;
    let funding_ts = latest_ts(&client, &schema, "funding_rate_snapshot").await?;
    let book_ts = latest_ts(&client, &schema, "microstructure_book_state").await?;
    let books_tracked = count_rows(&client, &schema, "microstructure_book_state").await?;
    let mut health = EngineHealth::default();
    health.status = "online".to_string();
    health.engine = engine.to_string();
    health.mode = mode.to_string();
    health.updated_at = Utc::now();
    health.data_dir = "postgres".to_string();
    health.assets = assets.to_vec();
    health.last_market_event_ts = market_ts.or(book_ts);
    health.last_funding_event_ts = funding_ts;
    health.books_tracked = books_tracked as usize;
    Ok(health)
}

async fn connect_postgres(dsn: &str, context_label: &str) -> Result<Client> {
    let sslmode = parse_sslmode(dsn);
    if sslmode != SslMode::Disable {
        let mut tls_builder = TlsConnector::builder();
        if matches!(sslmode, SslMode::Prefer | SslMode::Require) {
            tls_builder.danger_accept_invalid_certs(true);
            tls_builder.danger_accept_invalid_hostnames(true);
        } else if matches!(sslmode, SslMode::VerifyCa) {
            tls_builder.danger_accept_invalid_hostnames(true);
        }
        let tls = tls_builder.build().context("build postgres tls connector")?;
        let connector = MakeTlsConnector::new(tls);
        let (client, connection) = tokio_postgres::connect(dsn, connector)
            .await
            .context("connect postgres sink with tls")?;
        let label = context_label.to_string();
        tokio::spawn(async move {
            if let Err(error) = connection.await {
                warn!(error = %error, context = %label, "postgres connection closed");
            }
        });
        Ok(client)
    } else {
        let (client, connection) = tokio_postgres::connect(dsn, NoTls)
            .await
            .context("connect postgres sink without tls")?;
        let label = context_label.to_string();
        tokio::spawn(async move {
            if let Err(error) = connection.await {
                warn!(error = %error, context = %label, "postgres connection closed");
            }
        });
        Ok(client)
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SslMode {
    Disable,
    Prefer,
    Require,
    VerifyCa,
    VerifyFull,
}

fn parse_sslmode(dsn: &str) -> SslMode {
    for part in dsn.split(['?', '&']) {
        let value = part.strip_prefix("sslmode=");
        if let Some(mode) = value {
            return match mode {
                "disable" => SslMode::Disable,
                "prefer" => SslMode::Prefer,
                "verify-ca" => SslMode::VerifyCa,
                "verify-full" => SslMode::VerifyFull,
                _ => SslMode::Require,
            };
        }
    }
    SslMode::Disable
}

fn raw_event_row(
    event: &MarketEvent,
    received_at: DateTime<Utc>,
    run_id: &str,
    run_ts: DateTime<Utc>,
) -> Result<Option<RawEventRow>> {
    match event {
        MarketEvent::Trade(trade) => Ok(Some(raw_row_for_trade(trade, received_at, run_id, run_ts)?)),
        MarketEvent::L2Snapshot(snapshot) => Ok(Some(raw_row_for_snapshot(
            snapshot,
            received_at,
            run_id,
            run_ts,
        )?)),
        MarketEvent::L2Update(update) => Ok(Some(raw_row_for_update(update, received_at, run_id, run_ts)?)),
        MarketEvent::Bbo(quote) => {
            let payload = serde_json::to_string(quote)?;
            Ok(Some(base_raw_row(
                quote.timestamp,
                quote.asset,
                quote.venue,
                "bbo",
                format!("bbo-{}", quote.timestamp.to_rfc3339()),
                received_at,
                payload,
                run_id,
                run_ts,
            )
            .with_bid_ask(Some(quote.bid_price), Some(quote.ask_price))
            .with_depth(None, None)
            .with_side(None, None)
            .with_price_size(None, None)
            .with_update_ids(None, None)
            .build()))
        }
        MarketEvent::MarkIndex(mark) => {
            let payload = serde_json::to_string(mark)?;
            Ok(Some(base_raw_row(
                mark.timestamp,
                mark.asset,
                mark.venue,
                "mark_index",
                format!("mark-index-{}", mark.timestamp.to_rfc3339()),
                received_at,
                payload,
                run_id,
                run_ts,
            )
            .with_mark_open_interest(Some(mark.mark_price), None)
            .with_price_size(None, None)
            .with_side(None, None)
            .with_depth(None, None)
            .with_update_ids(None, None)
            .build()))
        }
        MarketEvent::BookStatus(status) => {
            let payload = serde_json::to_string(status)?;
            Ok(Some(base_raw_row(
                status.timestamp,
                status.asset,
                status.venue,
                "book_status",
                format!("book-status-{}-{}", status.venue, status.timestamp.to_rfc3339()),
                received_at,
                payload,
                run_id,
                run_ts,
            )
            .with_price_size(None, None)
            .with_side(None, None)
            .with_depth(None, None)
            .with_update_ids(None, None)
            .build()))
        }
    }
}

fn raw_row_for_trade(
    trade: &Trade,
    received_at: DateTime<Utc>,
    run_id: &str,
    run_ts: DateTime<Utc>,
) -> Result<RawEventRow> {
    let payload = serde_json::to_string(trade)?;
    Ok(
        base_raw_row(
            trade.timestamp,
            trade.asset,
            trade.venue,
            "trade",
            trade.trade_id.clone().unwrap_or_else(|| {
                format!(
                    "trade-{}-{}-{}-{}",
                    trade.timestamp.to_rfc3339(),
                    trade.price,
                    trade.size,
                    side_text(trade.side).unwrap_or("unknown")
                )
            }),
            received_at,
            payload,
            run_id,
            run_ts,
        )
        .with_side(side_text(trade.side).map(str::to_string), None)
        .with_price_size(Some(trade.price), Some(trade.size))
        .with_depth(None, None)
        .with_update_ids(None, None)
        .build(),
    )
}

fn raw_row_for_snapshot(
    snapshot: &L2Snapshot,
    received_at: DateTime<Utc>,
    run_id: &str,
    run_ts: DateTime<Utc>,
) -> Result<RawEventRow> {
    let payload = serde_json::to_string(snapshot)?;
    Ok(
        base_raw_row(
            snapshot.timestamp,
            snapshot.asset,
            snapshot.venue,
            "l2_snapshot",
            snapshot
                .last_update_id
                .map(|value| format!("l2-snapshot-{value}"))
                .unwrap_or_else(|| format!("l2-snapshot-{}", snapshot.timestamp.to_rfc3339())),
            received_at,
            payload,
            run_id,
            run_ts,
        )
        .with_depth(
            Some(depth_notional(&snapshot.bids)),
            Some(depth_notional(&snapshot.asks)),
        )
        .with_update_ids(None, snapshot.last_update_id)
        .build(),
    )
}

fn raw_row_for_update(
    update: &L2Update,
    received_at: DateTime<Utc>,
    run_id: &str,
    run_ts: DateTime<Utc>,
) -> Result<RawEventRow> {
    let payload = serde_json::to_string(update)?;
    Ok(
        base_raw_row(
            update.timestamp,
            update.asset,
            update.venue,
            "l2_update",
            format!(
                "l2-update-{}-{}",
                update
                    .first_update_id
                    .map(|value| value.to_string())
                    .unwrap_or_else(|| "na".to_string()),
                update
                    .last_update_id
                    .map(|value| value.to_string())
                    .unwrap_or_else(|| update.timestamp.to_rfc3339())
            ),
            received_at,
            payload,
            run_id,
            run_ts,
        )
        .with_depth(Some(depth_notional(&update.bids)), Some(depth_notional(&update.asks)))
        .with_update_ids(update.first_update_id, update.last_update_id)
        .build(),
    )
}

fn funding_row(
    event: &FundingEvent,
    run_id: &str,
    run_ts: DateTime<Utc>,
) -> Option<FundingSnapshotRow> {
    match event {
        FundingEvent::Funding(funding) => Some(base_funding_row(funding, run_id, run_ts)),
        FundingEvent::Oi(_) | FundingEvent::Liquidation(_) => None,
    }
}

fn base_funding_row(funding: &FundingRate, run_id: &str, run_ts: DateTime<Utc>) -> FundingSnapshotRow {
    FundingSnapshotRow {
        ts: funding.timestamp,
        date: funding.timestamp.date_naive(),
        venue: funding.venue.to_string(),
        underlying: asset_text(funding.asset),
        source: "microstructure_live".to_string(),
        interest_1h: Some(funding.rate),
        interest_8h: None,
        quality_flag: "ok".to_string(),
        logical_ts: funding.timestamp,
        run_id: run_id.to_string(),
        run_ts,
        write_mode: "immutable".to_string(),
    }
}

fn book_state_row(
    timestamp: DateTime<Utc>,
    asset: Asset,
    venue: Venue,
    state: &BookRuntimeState,
    run_id: &str,
    run_ts: DateTime<Utc>,
) -> BookStateRow {
    let best_bid = state.book.best_bid();
    let best_ask = state.book.best_ask();
    let mid = state.book.mid();
    let depth_bid_5 = state.book.depth_bid(5);
    let depth_ask_5 = state.book.depth_ask(5);
    let book_quality = if state.complete {
        "complete"
    } else if state.is_gap_detected {
        "gapped"
    } else if !state.is_seeded {
        "unseeded"
    } else if !state.is_sequence_valid {
        "sequence_invalid"
    } else {
        "partial"
    };
    BookStateRow {
        ts: timestamp,
        date: timestamp.date_naive(),
        asset: asset_text(asset),
        venue: venue.to_string(),
        exchange_symbol: asset.symbol(venue).to_string(),
        venue_group: venue_group(venue).to_string(),
        market_type: market_type(venue).to_string(),
        is_derivatives: market_type(venue) != "spot",
        contract_type: contract_type(venue).to_string(),
        best_bid: best_bid.map(|(price, _)| price),
        best_ask: best_ask.map(|(price, _)| price),
        best_bid_size: best_bid.map(|(_, size)| size),
        best_ask_size: best_ask.map(|(_, size)| size),
        mid_price: mid,
        microprice: state.book.microprice(),
        spread: state.book.spread(),
        spread_rel: state.book.spread_rel(),
        depth_bid_5,
        depth_ask_5,
        depth_notional_bid_5: mid.map(|value| value * depth_bid_5),
        depth_notional_ask_5: mid.map(|value| value * depth_ask_5),
        imbalance_5: state.book.imbalance(5),
        is_seeded: state.is_seeded,
        is_sequence_valid: state.is_sequence_valid,
        is_gap_detected: state.is_gap_detected,
        reseed_count: state.reseed_count as i64,
        book_quality: book_quality.to_string(),
        quality_flag: if state.complete { "ok" } else { "partial" }.to_string(),
        logical_ts: timestamp,
        run_id: run_id.to_string(),
        run_ts,
        write_mode: "immutable".to_string(),
    }
}

async fn latest_ts(client: &Client, schema: &str, table: &str) -> Result<Option<DateTime<Utc>>> {
    let sql = format!("SELECT MAX(ts) AS latest_ts FROM {schema}.{table}");
    let row = client.query_one(sql.as_str(), &[]).await?;
    Ok(row.get("latest_ts"))
}

async fn count_rows(client: &Client, schema: &str, table: &str) -> Result<i64> {
    let sql = format!("SELECT COUNT(*) AS row_count FROM {schema}.{table}");
    let row = client.query_one(sql.as_str(), &[]).await?;
    Ok(row.get::<_, i64>("row_count"))
}

fn quoted_schema(schema: &str) -> String {
    format!("\"{}\"", schema.replace('"', "\"\""))
}

fn venue_group(venue: Venue) -> &'static str {
    match venue {
        Venue::BinanceSpot | Venue::BinanceFutures => "binance",
        Venue::BybitSpot | Venue::BybitPerps => "bybit",
        Venue::Deribit => "deribit",
    }
}

fn market_type(venue: Venue) -> &'static str {
    match venue {
        Venue::BinanceSpot | Venue::BybitSpot => "spot",
        Venue::BinanceFutures => "futures",
        Venue::BybitPerps | Venue::Deribit => "perp",
    }
}

fn contract_type(venue: Venue) -> &'static str {
    match market_type(venue) {
        "spot" => "spot",
        "futures" => "linear_future",
        _ => "linear_perp",
    }
}

fn asset_text(asset: Asset) -> String {
    asset.to_string().to_uppercase()
}

fn side_text(side: Side) -> Option<&'static str> {
    Some(match side {
        Side::Buy => "buy",
        Side::Sell => "sell",
    })
}

fn depth_notional(levels: &[uforia_ms_types::BookLevel]) -> f64 {
    levels.iter().take(5).map(|level| level.price.into_inner() * level.size).sum()
}

struct RawEventRowBuilder(RawEventRow);

impl RawEventRowBuilder {
    fn with_side(mut self, side: Option<String>, liquidation_side: Option<String>) -> Self {
        self.0.side = side;
        self.0.liquidation_side = liquidation_side;
        self
    }

    fn with_price_size(mut self, price: Option<f64>, size: Option<f64>) -> Self {
        self.0.price = price;
        self.0.size = size;
        self.0.trade_notional = match (price, size) {
            (Some(price), Some(size)) => Some(price * size),
            _ => None,
        };
        self
    }

    fn with_bid_ask(mut self, bid: Option<f64>, ask: Option<f64>) -> Self {
        self.0.bid = bid;
        self.0.ask = ask;
        self
    }

    fn with_mark_open_interest(mut self, mark_price: Option<f64>, open_interest: Option<f64>) -> Self {
        self.0.mark_price = mark_price;
        self.0.open_interest = open_interest;
        self
    }

    fn with_depth(mut self, bid_depth: Option<f64>, ask_depth: Option<f64>) -> Self {
        self.0.depth_notional_bid_5 = bid_depth;
        self.0.depth_notional_ask_5 = ask_depth;
        self
    }

    fn with_update_ids(mut self, first_update_id: Option<u64>, last_update_id: Option<u64>) -> Self {
        self.0.first_update_id = first_update_id;
        self.0.last_update_id = last_update_id;
        self
    }

    fn build(self) -> RawEventRow {
        self.0
    }
}

fn base_raw_row(
    ts: DateTime<Utc>,
    asset: Asset,
    venue: Venue,
    event_type: &str,
    event_id: String,
    received_at: DateTime<Utc>,
    payload: String,
    run_id: &str,
    run_ts: DateTime<Utc>,
) -> RawEventRowBuilder {
    RawEventRowBuilder(RawEventRow {
        ts,
        date: ts.date_naive(),
        asset: asset_text(asset),
        venue: venue.to_string(),
        event_type: event_type.to_string(),
        event_id,
        exchange_symbol: asset.symbol(venue).to_string(),
        venue_group: venue_group(venue).to_string(),
        market_type: market_type(venue).to_string(),
        is_derivatives: market_type(venue) != "spot",
        contract_type: contract_type(venue).to_string(),
        exchange_ts: ts,
        received_ts: received_at,
        local_ts: received_at,
        transaction_ts: ts,
        side: None,
        liquidation_side: None,
        price: None,
        size: None,
        trade_notional: None,
        bid: None,
        ask: None,
        mark_price: None,
        open_interest: None,
        depth_notional_bid_5: None,
        depth_notional_ask_5: None,
        first_update_id: None,
        last_update_id: None,
        payload,
        quality_flag: "ok".to_string(),
        logical_ts: ts,
        run_id: run_id.to_string(),
        run_ts,
        write_mode: "immutable".to_string(),
    })
}
