Skip to main content

scope/cli/
monitor.rs

1//! # Live Token Monitor
2//!
3//! This module implements a real-time terminal UI for monitoring token metrics.
4//! It displays live-updating charts for price, volume, transactions, and liquidity
5//! across four switchable layout presets with responsive terminal sizing.
6//!
7//! ## Usage
8//!
9//! Directly from the command line (no interactive mode required):
10//! ```text
11//! scope monitor USDC
12//! scope mon PEPE --chain ethereum --layout chart-focus --refresh 3
13//! scope monitor 0x1234... -c solana -s log --color-scheme blue-orange
14//! ```
15//!
16//! Or from interactive mode:
17//! ```text
18//! scope> monitor USDC
19//! scope> mon 0x1234...
20//! ```
21//!
22//! ## Layout Presets
23//!
24//! - **Dashboard** -- Charts top, gauges middle, transaction feed bottom (default)
25//! - **ChartFocus** -- Full-width candles (~85%), minimal stats overlay below
26//! - **Feed** -- Transaction log takes priority (~75%), small metrics + buy/sell on top
27//! - **Compact** -- Price sparkline and metrics only, for small terminals (<80x24)
28//! - **Exchange** -- Order book + chart + market info (exchange-style view)
29//!
30//! The monitor auto-selects a layout based on terminal dimensions (responsive
31//! breakpoints). Manual switching via `L`/`H` disables auto-selection until `A`.
32//!
33//! ## Features
34//!
35//! - Real-time price chart (line, candlestick, or volume profile) with sliding window
36//! - Volume bar chart
37//! - Buy/sell ratio gauge
38//! - Scrollable activity feed (transaction log)
39//! - Key metrics panel with sparkline and stats table
40//! - Config-driven widget visibility (toggle any widget on/off)
41//! - Four layout presets switchable at runtime
42//! - Responsive terminal sizing with auto-layout
43//! - Log/linear Y-axis scale toggle
44//! - Three color schemes (Green/Red, Blue/Orange, Monochrome)
45//! - On-chain holder count integration (when chain client is available)
46//! - Per-pair liquidity depth breakdown across DEXes
47//! - Configurable price alerts (min/max thresholds, whale detection, volume spikes)
48//! - CSV export mode (toggle with `E`, writes to `./scope-exports/`)
49//! - Auto-pause on user input (toggle with `Shift+P`)
50//!
51//! ## Keyboard Controls
52//!
53//! - `Q`/`Esc` quit, `R` refresh, `P`/`Space` pause
54//! - `Shift+P` toggle auto-pause on input
55//! - `E` toggle CSV export (REC indicator when active)
56//! - `L`/`H` cycle layout forward/backward
57//! - `W` + `1-5` toggle widget visibility
58//! - `A` re-enable auto layout
59//! - `C` toggle chart mode, `S` toggle log/linear scale, `/` cycle color scheme
60//! - `T`/`Tab` cycle time period, `1-6` select period
61//! - `J`/`K` scroll activity log, `+`/`-` adjust refresh speed
62
63use crate::chains::dex::{DexClient, DexDataSource, DexTokenData};
64use crate::chains::{ChainClient, ChainClientFactory, DexPair};
65use crate::config::Config;
66use crate::error::{Result, ScopeError};
67use crate::market::{OrderBook, OrderBookLevel, Trade, TradeSide};
68use clap::Args;
69use crossterm::{
70    event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
71    execute,
72};
73use ratatui::{
74    Frame,
75    layout::{Constraint, Direction, Layout, Rect},
76    style::{Color, Style},
77    symbols,
78    text::{Line, Span},
79    widgets::{
80        Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, GraphType, List, ListItem,
81        ListState, Paragraph, Row, Sparkline, Table, Tabs,
82        canvas::{Canvas, Line as CanvasLine, Rectangle},
83    },
84};
85use serde::{Deserialize, Serialize};
86use std::collections::VecDeque;
87use std::fs;
88use std::io::{self, BufWriter, Write as _};
89use std::path::PathBuf;
90use std::time::{Duration, Instant};
91
92use super::interactive::SessionContext;
93
94// ============================================================================
95// CLI Arguments
96// ============================================================================
97
98/// Arguments for the top-level `monitor` command.
99///
100/// Launches the live TUI dashboard directly from the command line,
101/// without requiring interactive mode.
102///
103/// # Examples
104///
105/// ```bash
106/// # Monitor by token address
107/// scope monitor 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
108///
109/// # Monitor by symbol on a specific chain
110/// scope monitor USDC --chain ethereum
111///
112/// # Short alias
113/// scope mon PEPE -c ethereum
114///
115/// # Custom layout and refresh rate
116/// scope monitor USDC --layout chart-focus --refresh 3
117/// ```
118#[derive(Debug, Args)]
119pub struct MonitorArgs {
120    /// Token address or symbol to monitor.
121    ///
122    /// Can be a contract address (0x...) or a token symbol/name.
123    /// If a name/symbol is provided, matching tokens will be searched
124    /// and you can select from the results.
125    pub token: String,
126
127    /// Target blockchain network.
128    ///
129    /// Determines which chain to query for token data.
130    #[arg(short, long, default_value = "ethereum")]
131    pub chain: String,
132
133    /// Layout preset for the TUI dashboard.
134    ///
135    /// Controls how widgets are arranged on screen.
136    /// Options: dashboard, chart-focus, feed, compact, exchange.
137    #[arg(short, long)]
138    pub layout: Option<LayoutPreset>,
139
140    /// Refresh interval in seconds.
141    ///
142    /// How often to fetch new data from the API.
143    /// Adjustable at runtime with +/- keys.
144    #[arg(short, long)]
145    pub refresh: Option<u64>,
146
147    /// Y-axis scale mode for price charts.
148    ///
149    /// Options: linear, log.
150    #[arg(short, long)]
151    pub scale: Option<ScaleMode>,
152
153    /// Color scheme for charts.
154    ///
155    /// Options: green-red, blue-orange, monochrome.
156    #[arg(long)]
157    pub color_scheme: Option<ColorScheme>,
158
159    /// Start CSV export immediately, writing to the given path.
160    #[arg(short, long, value_name = "PATH")]
161    pub export: Option<PathBuf>,
162
163    /// Exchange venue for real OHLC candle data (e.g., binance, mexc).
164    ///
165    /// When specified, the monitor fetches real candlestick data from the
166    /// exchange API instead of generating synthetic candles from price history.
167    #[arg(long, value_name = "VENUE")]
168    pub venue: Option<String>,
169
170    /// Direct trading pair for exchange-only mode (e.g., PUSD_USDT).
171    ///
172    /// Bypasses DexScreener token resolution entirely and uses the exchange
173    /// ticker as the data source. Requires `--venue` to be specified.
174    /// Use this when the token is not listed on DexScreener.
175    #[arg(long, value_name = "PAIR")]
176    pub pair: Option<String>,
177}
178
179/// Maximum data retention: 24 hours.
180/// At 5-second intervals: 24 * 60 * 12 = 17,280 points max per history.
181/// With DataPoint at 24 bytes: ~415 KB per history, ~830 KB total.
182/// Data is persisted to OS temp folder for session continuity.
183const MAX_DATA_AGE_SECS: f64 = 24.0 * 3600.0; // 24 hours
184
185/// Cache file prefix in temp directory.
186const CACHE_FILE_PREFIX: &str = "bcc_monitor_";
187
188/// Default refresh interval in seconds.
189const DEFAULT_REFRESH_SECS: u64 = 5;
190
191/// Minimum refresh interval in seconds.
192const MIN_REFRESH_SECS: u64 = 1;
193
194/// Maximum refresh interval in seconds.
195const MAX_REFRESH_SECS: u64 = 60;
196
197/// A data point with timestamp, value, and whether it's real (from API) or synthetic.
198#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
199pub struct DataPoint {
200    /// Unix timestamp in seconds.
201    pub timestamp: f64,
202    /// Value (price or volume).
203    pub value: f64,
204    /// True if this is real data from API, false if synthetic/estimated.
205    pub is_real: bool,
206}
207
208/// OHLC (Open-High-Low-Close) candlestick data for a time period.
209#[derive(Debug, Clone, Copy)]
210pub struct OhlcCandle {
211    /// Start timestamp of this candle period.
212    pub timestamp: f64,
213    /// Opening price.
214    pub open: f64,
215    /// Highest price during the period.
216    pub high: f64,
217    /// Lowest price during the period.
218    pub low: f64,
219    /// Closing price.
220    pub close: f64,
221    /// Whether this candle is bullish (close >= open).
222    pub is_bullish: bool,
223}
224
225impl OhlcCandle {
226    /// Creates a new candle from a single price point.
227    pub fn new(timestamp: f64, price: f64) -> Self {
228        Self {
229            timestamp,
230            open: price,
231            high: price,
232            low: price,
233            close: price,
234            is_bullish: true,
235        }
236    }
237
238    /// Updates the candle with a new price.
239    pub fn update(&mut self, price: f64) {
240        self.high = self.high.max(price);
241        self.low = self.low.min(price);
242        self.close = price;
243        self.is_bullish = self.close >= self.open;
244    }
245}
246
247/// Cached monitor data that persists between sessions.
248#[derive(Debug, Serialize, Deserialize)]
249struct CachedMonitorData {
250    /// Token address this cache is for.
251    token_address: String,
252    /// Chain identifier.
253    chain: String,
254    /// Price history data points.
255    price_history: Vec<DataPoint>,
256    /// Volume history data points.
257    volume_history: Vec<DataPoint>,
258    /// Timestamp when cache was saved.
259    saved_at: f64,
260}
261
262/// Time period for chart display (limited to 24 hours of data retention).
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum TimePeriod {
265    /// Last 1 minute
266    Min1,
267    /// Last 5 minutes
268    Min5,
269    /// Last 15 minutes
270    Min15,
271    /// Last 1 hour
272    Hour1,
273    /// Last 4 hours
274    Hour4,
275    /// Last 24 hours (1 day)
276    Day1,
277}
278
279impl TimePeriod {
280    /// Returns the duration in seconds for this period.
281    pub fn duration_secs(&self) -> i64 {
282        match self {
283            TimePeriod::Min1 => 60,
284            TimePeriod::Min5 => 5 * 60,
285            TimePeriod::Min15 => 15 * 60,
286            TimePeriod::Hour1 => 3600,
287            TimePeriod::Hour4 => 4 * 3600,
288            TimePeriod::Day1 => 24 * 3600,
289        }
290    }
291
292    /// Returns a display label for this period.
293    pub fn label(&self) -> &'static str {
294        match self {
295            TimePeriod::Min1 => "1m",
296            TimePeriod::Min5 => "5m",
297            TimePeriod::Min15 => "15m",
298            TimePeriod::Hour1 => "1h",
299            TimePeriod::Hour4 => "4h",
300            TimePeriod::Day1 => "1d",
301        }
302    }
303
304    /// Returns the zero-based index for this period (for Tabs widget).
305    pub fn index(&self) -> usize {
306        match self {
307            TimePeriod::Min1 => 0,
308            TimePeriod::Min5 => 1,
309            TimePeriod::Min15 => 2,
310            TimePeriod::Hour1 => 3,
311            TimePeriod::Hour4 => 4,
312            TimePeriod::Day1 => 5,
313        }
314    }
315
316    /// Returns the exchange API kline interval string for this period.
317    pub fn exchange_interval(&self) -> &'static str {
318        match self {
319            TimePeriod::Min1 => "1m",
320            TimePeriod::Min5 => "5m",
321            TimePeriod::Min15 => "15m",
322            TimePeriod::Hour1 => "1h",
323            TimePeriod::Hour4 => "4h",
324            TimePeriod::Day1 => "1d",
325        }
326    }
327
328    /// Cycles to the next time period.
329    pub fn next(&self) -> Self {
330        match self {
331            TimePeriod::Min1 => TimePeriod::Min5,
332            TimePeriod::Min5 => TimePeriod::Min15,
333            TimePeriod::Min15 => TimePeriod::Hour1,
334            TimePeriod::Hour1 => TimePeriod::Hour4,
335            TimePeriod::Hour4 => TimePeriod::Day1,
336            TimePeriod::Day1 => TimePeriod::Min1,
337        }
338    }
339}
340
341impl std::fmt::Display for TimePeriod {
342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343        write!(f, "{}", self.label())
344    }
345}
346
347/// Chart display mode.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
349pub enum ChartMode {
350    /// Line chart showing price over time.
351    #[default]
352    Line,
353    /// Candlestick chart showing OHLC data.
354    Candlestick,
355    /// Volume profile showing volume distribution by price level.
356    VolumeProfile,
357}
358
359impl ChartMode {
360    /// Cycles to the next chart mode.
361    pub fn next(&self) -> Self {
362        match self {
363            ChartMode::Line => ChartMode::Candlestick,
364            ChartMode::Candlestick => ChartMode::VolumeProfile,
365            ChartMode::VolumeProfile => ChartMode::Line,
366        }
367    }
368
369    /// Returns a display label for this mode.
370    pub fn label(&self) -> &'static str {
371        match self {
372            ChartMode::Line => "Line",
373            ChartMode::Candlestick => "Candle",
374            ChartMode::VolumeProfile => "VolPro",
375        }
376    }
377}
378
379/// Color scheme for the monitor TUI.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
381#[serde(rename_all = "kebab-case")]
382pub enum ColorScheme {
383    /// Classic green/red (default).
384    #[default]
385    GreenRed,
386    /// Blue/orange, better for certain color blindness.
387    BlueOrange,
388    /// Monochrome -- fully accessible grayscale.
389    Monochrome,
390}
391
392impl ColorScheme {
393    /// Cycles to the next color scheme.
394    pub fn next(&self) -> Self {
395        match self {
396            ColorScheme::GreenRed => ColorScheme::BlueOrange,
397            ColorScheme::BlueOrange => ColorScheme::Monochrome,
398            ColorScheme::Monochrome => ColorScheme::GreenRed,
399        }
400    }
401
402    /// Returns the named color palette for this scheme.
403    pub fn palette(&self) -> ColorPalette {
404        match self {
405            ColorScheme::GreenRed => ColorPalette {
406                up: Color::Green,
407                down: Color::Red,
408                neutral: Color::Gray,
409                header_fg: Color::White,
410                border: Color::DarkGray,
411                highlight: Color::Yellow,
412                volume_bar: Color::Blue,
413                sparkline: Color::Cyan,
414            },
415            ColorScheme::BlueOrange => ColorPalette {
416                up: Color::Blue,
417                down: Color::Rgb(255, 165, 0), // orange
418                neutral: Color::Gray,
419                header_fg: Color::White,
420                border: Color::DarkGray,
421                highlight: Color::Cyan,
422                volume_bar: Color::Magenta,
423                sparkline: Color::LightBlue,
424            },
425            ColorScheme::Monochrome => ColorPalette {
426                up: Color::White,
427                down: Color::DarkGray,
428                neutral: Color::Gray,
429                header_fg: Color::White,
430                border: Color::DarkGray,
431                highlight: Color::White,
432                volume_bar: Color::Gray,
433                sparkline: Color::White,
434            },
435        }
436    }
437
438    /// Returns a short display label.
439    pub fn label(&self) -> &'static str {
440        match self {
441            ColorScheme::GreenRed => "G/R",
442            ColorScheme::BlueOrange => "B/O",
443            ColorScheme::Monochrome => "Mono",
444        }
445    }
446}
447
448/// Named color palette derived from a ColorScheme.
449#[derive(Debug, Clone, Copy)]
450pub struct ColorPalette {
451    /// Color for price-up / bullish.
452    pub up: Color,
453    /// Color for price-down / bearish.
454    pub down: Color,
455    /// Neutral/secondary text color.
456    pub neutral: Color,
457    /// Header foreground.
458    pub header_fg: Color,
459    /// Border color.
460    pub border: Color,
461    /// Highlight/accent color.
462    pub highlight: Color,
463    /// Volume bar color.
464    pub volume_bar: Color,
465    /// Sparkline color.
466    pub sparkline: Color,
467}
468
469/// Y-axis scaling mode for price charts.
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
471#[serde(rename_all = "kebab-case")]
472pub enum ScaleMode {
473    /// Linear scale (default).
474    #[default]
475    Linear,
476    /// Logarithmic scale -- useful for tokens with very wide price ranges.
477    Log,
478}
479
480impl ScaleMode {
481    /// Toggles between Linear and Log.
482    pub fn toggle(&self) -> Self {
483        match self {
484            ScaleMode::Linear => ScaleMode::Log,
485            ScaleMode::Log => ScaleMode::Linear,
486        }
487    }
488
489    /// Returns a short display label.
490    pub fn label(&self) -> &'static str {
491        match self {
492            ScaleMode::Linear => "Lin",
493            ScaleMode::Log => "Log",
494        }
495    }
496}
497
498/// Alert configuration for price and whale detection.
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
500#[serde(default)]
501pub struct AlertConfig {
502    /// Minimum price threshold; alert fires when price drops below this.
503    pub price_min: Option<f64>,
504    /// Maximum price threshold; alert fires when price exceeds this.
505    pub price_max: Option<f64>,
506    /// Minimum USD value for whale transaction detection.
507    pub whale_min_usd: Option<f64>,
508    /// Volume spike threshold as a percentage increase from the rolling average.
509    pub volume_spike_threshold_pct: Option<f64>,
510}
511
512impl Default for AlertConfig {
513    #[allow(clippy::derivable_impls)]
514    fn default() -> Self {
515        Self {
516            price_min: None,
517            price_max: None,
518            whale_min_usd: None,
519            volume_spike_threshold_pct: None,
520        }
521    }
522}
523
524/// An active (currently firing) alert with a description.
525#[derive(Debug, Clone)]
526pub struct ActiveAlert {
527    /// Human-readable message describing the alert.
528    pub message: String,
529    /// When the alert was first triggered.
530    pub triggered_at: Instant,
531}
532
533/// CSV export configuration.
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
535#[serde(default)]
536pub struct ExportConfig {
537    /// Base directory for exports (default: `./scope-exports/`).
538    pub path: Option<String>,
539}
540
541impl Default for ExportConfig {
542    #[allow(clippy::derivable_impls)]
543    fn default() -> Self {
544        Self { path: None }
545    }
546}
547
548/// Layout preset for the monitor TUI.
549///
550/// Controls which widgets are shown and how they are arranged.
551/// Can be switched at runtime with keybindings or set via config.
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
553#[serde(rename_all = "kebab-case")]
554pub enum LayoutPreset {
555    /// Balanced 2x2 grid with all widgets visible.
556    #[default]
557    Dashboard,
558    /// Price chart takes ~85% of the screen; minimal stats overlay.
559    ChartFocus,
560    /// Transaction/activity feed prioritized; small price ticker.
561    Feed,
562    /// Minimal single-column sparkline view for small terminals.
563    Compact,
564    /// Exchange-style view: order book + chart + market info.
565    Exchange,
566}
567
568impl LayoutPreset {
569    /// Cycles to the next layout preset.
570    pub fn next(&self) -> Self {
571        match self {
572            LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
573            LayoutPreset::ChartFocus => LayoutPreset::Feed,
574            LayoutPreset::Feed => LayoutPreset::Compact,
575            LayoutPreset::Compact => LayoutPreset::Exchange,
576            LayoutPreset::Exchange => LayoutPreset::Dashboard,
577        }
578    }
579
580    /// Cycles to the previous layout preset.
581    pub fn prev(&self) -> Self {
582        match self {
583            LayoutPreset::Dashboard => LayoutPreset::Exchange,
584            LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
585            LayoutPreset::Feed => LayoutPreset::ChartFocus,
586            LayoutPreset::Compact => LayoutPreset::Feed,
587            LayoutPreset::Exchange => LayoutPreset::Compact,
588        }
589    }
590
591    /// Returns a display label for this preset.
592    pub fn label(&self) -> &'static str {
593        match self {
594            LayoutPreset::Dashboard => "Dashboard",
595            LayoutPreset::ChartFocus => "Chart",
596            LayoutPreset::Feed => "Feed",
597            LayoutPreset::Compact => "Compact",
598            LayoutPreset::Exchange => "Exchange",
599        }
600    }
601}
602
603/// Controls which widgets are visible in the monitor.
604///
605/// Individual widgets can be toggled on/off via keybindings or config.
606/// The layout functions use these flags to decide which `Rect` areas to allocate.
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
608#[serde(default)]
609pub struct WidgetVisibility {
610    /// Show the price chart (line or candlestick).
611    pub price_chart: bool,
612    /// Show the volume bar chart.
613    pub volume_chart: bool,
614    /// Show the buy/sell pressure gauge and activity log.
615    pub buy_sell_pressure: bool,
616    /// Show the metrics panel (sparkline + key metrics table).
617    pub metrics_panel: bool,
618    /// Show the activity log feed.
619    pub activity_log: bool,
620    /// Show the holder count in the metrics panel.
621    pub holder_count: bool,
622    /// Show the per-pair liquidity depth.
623    pub liquidity_depth: bool,
624}
625
626impl Default for WidgetVisibility {
627    fn default() -> Self {
628        Self {
629            price_chart: true,
630            volume_chart: true,
631            buy_sell_pressure: true,
632            metrics_panel: true,
633            activity_log: true,
634            holder_count: true,
635            liquidity_depth: true,
636        }
637    }
638}
639
640impl WidgetVisibility {
641    /// Returns the number of visible widgets.
642    pub fn visible_count(&self) -> usize {
643        [
644            self.price_chart,
645            self.volume_chart,
646            self.buy_sell_pressure,
647            self.metrics_panel,
648            self.activity_log,
649        ]
650        .iter()
651        .filter(|&&v| v)
652        .count()
653    }
654
655    /// Toggles a widget by index (1-based: 1=price_chart, 2=volume, 3=buy_sell, 4=metrics, 5=log).
656    pub fn toggle_by_index(&mut self, index: usize) {
657        match index {
658            1 => self.price_chart = !self.price_chart,
659            2 => self.volume_chart = !self.volume_chart,
660            3 => self.buy_sell_pressure = !self.buy_sell_pressure,
661            4 => self.metrics_panel = !self.metrics_panel,
662            5 => self.activity_log = !self.activity_log,
663            _ => {}
664        }
665    }
666}
667
668/// Monitor-specific configuration.
669///
670/// Loaded from the `monitor:` section of `~/.config/scope/config.yaml`.
671/// All fields have sensible defaults so the section is entirely optional.
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
673#[serde(default)]
674pub struct MonitorConfig {
675    /// Layout preset to use on startup.
676    pub layout: LayoutPreset,
677    /// Refresh interval in seconds.
678    pub refresh_seconds: u64,
679    /// Widget visibility toggles.
680    pub widgets: WidgetVisibility,
681    /// Y-axis scale mode for price charts.
682    pub scale: ScaleMode,
683    /// Color scheme.
684    pub color_scheme: ColorScheme,
685    /// Alert thresholds (price min/max, whale detection).
686    pub alerts: AlertConfig,
687    /// CSV export settings.
688    pub export: ExportConfig,
689    /// Whether to auto-pause data fetching when the user is interacting.
690    pub auto_pause_on_input: bool,
691    /// Exchange venue for real OHLC candle data (e.g., "binance").
692    #[serde(default)]
693    pub venue: Option<String>,
694}
695
696impl Default for MonitorConfig {
697    fn default() -> Self {
698        Self {
699            layout: LayoutPreset::Dashboard,
700            refresh_seconds: DEFAULT_REFRESH_SECS,
701            widgets: WidgetVisibility::default(),
702            scale: ScaleMode::Linear,
703            color_scheme: ColorScheme::GreenRed,
704            alerts: AlertConfig::default(),
705            export: ExportConfig::default(),
706            auto_pause_on_input: false,
707            venue: None,
708        }
709    }
710}
711
712/// State for the live token monitor.
713pub struct MonitorState {
714    /// Token contract address.
715    pub token_address: String,
716
717    /// Token symbol.
718    pub symbol: String,
719
720    /// Token name.
721    pub name: String,
722
723    /// Blockchain network.
724    pub chain: String,
725
726    /// Historical price data points with real/synthetic indicator.
727    pub price_history: VecDeque<DataPoint>,
728
729    /// Historical volume data points with real/synthetic indicator.
730    pub volume_history: VecDeque<DataPoint>,
731
732    /// Count of real (non-synthetic) data points.
733    pub real_data_count: usize,
734
735    /// Current price in USD.
736    pub current_price: f64,
737
738    /// 24-hour price change percentage.
739    pub price_change_24h: f64,
740
741    /// 6-hour price change percentage.
742    pub price_change_6h: f64,
743
744    /// 1-hour price change percentage.
745    pub price_change_1h: f64,
746
747    /// 5-minute price change percentage.
748    pub price_change_5m: f64,
749
750    /// Timestamp when the price last changed (Unix timestamp).
751    pub last_price_change_at: f64,
752
753    /// Previous price for change detection.
754    pub previous_price: f64,
755
756    /// Total buy transactions in 24 hours.
757    pub buys_24h: u64,
758
759    /// Total sell transactions in 24 hours.
760    pub sells_24h: u64,
761
762    /// Total liquidity in USD.
763    pub liquidity_usd: f64,
764
765    /// 24-hour volume in USD.
766    pub volume_24h: f64,
767
768    /// Market capitalization.
769    pub market_cap: Option<f64>,
770
771    /// Fully diluted valuation.
772    pub fdv: Option<f64>,
773
774    /// Last update timestamp.
775    pub last_update: Instant,
776
777    /// Refresh rate.
778    pub refresh_rate: Duration,
779
780    /// Whether monitoring is paused.
781    pub paused: bool,
782
783    /// Recent log messages.
784    pub log_messages: VecDeque<String>,
785
786    /// Scroll state for the activity log list widget.
787    pub log_list_state: ListState,
788
789    /// Error message to display (if any).
790    pub error_message: Option<String>,
791
792    /// Selected time period for chart display.
793    pub time_period: TimePeriod,
794
795    /// Chart display mode (line or candlestick).
796    pub chart_mode: ChartMode,
797
798    /// Y-axis scale mode for price charts (Linear or Log).
799    pub scale_mode: ScaleMode,
800
801    /// Active color scheme.
802    pub color_scheme: ColorScheme,
803
804    /// Holder count (fetched from chain client, if available).
805    pub holder_count: Option<u64>,
806
807    /// Per-pair liquidity data: (pair_name, liquidity_usd).
808    pub liquidity_pairs: Vec<(String, f64)>,
809
810    /// Synthetic order book generated from DEX pair data.
811    pub order_book: Option<OrderBook>,
812
813    /// Recent trades (synthetic from DEX pair data or real from exchange API).
814    pub recent_trades: VecDeque<Trade>,
815
816    /// Raw DEX pair data for the exchange view.
817    pub dex_pairs: Vec<DexPair>,
818
819    /// Token metadata: website URLs.
820    pub websites: Vec<String>,
821
822    /// Token metadata: social links (name, url).
823    pub socials: Vec<(String, String)>,
824
825    /// Earliest pair creation timestamp (for "listed since").
826    pub earliest_pair_created_at: Option<i64>,
827
828    /// DexScreener URL for this token.
829    pub dexscreener_url: Option<String>,
830
831    /// Counter to throttle holder count fetches.
832    pub holder_fetch_counter: u32,
833
834    /// Unix timestamp when monitoring started.
835    pub start_timestamp: i64,
836
837    /// Current layout preset.
838    pub layout: LayoutPreset,
839
840    /// Widget visibility toggles.
841    pub widgets: WidgetVisibility,
842
843    /// Whether responsive auto-layout is active (disabled by manual layout switch).
844    pub auto_layout: bool,
845
846    /// Whether the widget-toggle input mode is active (waiting for digit 1-5).
847    pub widget_toggle_mode: bool,
848
849    // ── Phase 7: Alert System ──
850    /// Alert configuration thresholds.
851    pub alerts: AlertConfig,
852
853    /// Currently firing alerts.
854    pub active_alerts: Vec<ActiveAlert>,
855
856    /// Visual flash timer for alert overlay.
857    pub alert_flash_until: Option<Instant>,
858
859    // ── Phase 8: CSV Export ──
860    /// Whether CSV export is currently active.
861    pub export_active: bool,
862
863    /// Path to the current export file.
864    pub export_path: Option<PathBuf>,
865
866    /// Rolling volume average for spike detection (simple moving average).
867    pub volume_avg: f64,
868
869    // ── Phase 9: Auto-Pause ──
870    /// Whether auto-pause on user input is enabled.
871    pub auto_pause_on_input: bool,
872
873    /// Timestamp of the last user key input.
874    pub last_input_at: Instant,
875
876    /// Duration after last input before auto-pause lifts (default 3s).
877    pub auto_pause_timeout: Duration,
878
879    // ── Exchange OHLC ──
880    /// Real OHLC candles fetched from an exchange venue.
881    /// When present, `get_ohlc_candles` uses these instead of synthetic candles.
882    pub exchange_ohlc: Vec<OhlcCandle>,
883
884    /// Venue-formatted pair symbol for OHLC queries (e.g., "BTCUSDT").
885    pub venue_pair: Option<String>,
886}
887
888impl MonitorState {
889    /// Creates a new monitor state from initial token data.
890    /// Attempts to load cached data from disk first.
891    pub fn new(token_data: &DexTokenData, chain: &str) -> Self {
892        let now = Instant::now();
893        let now_ts = chrono::Utc::now().timestamp() as f64;
894
895        // Try to load cached data first
896        let (price_history, volume_history, real_data_count) =
897            if let Some(cached) = Self::load_cache(&token_data.address, chain) {
898                // Filter out data older than 24 hours
899                let cutoff = now_ts - MAX_DATA_AGE_SECS;
900                let price_hist: VecDeque<DataPoint> = cached
901                    .price_history
902                    .into_iter()
903                    .filter(|p| p.timestamp >= cutoff)
904                    .collect();
905                let vol_hist: VecDeque<DataPoint> = cached
906                    .volume_history
907                    .into_iter()
908                    .filter(|p| p.timestamp >= cutoff)
909                    .collect();
910                let real_count = price_hist.iter().filter(|p| p.is_real).count();
911                (price_hist, vol_hist, real_count)
912            } else {
913                // Generate synthetic historical data from price change percentages
914                let price_hist = Self::generate_synthetic_price_history(
915                    token_data.price_usd,
916                    token_data.price_change_1h,
917                    token_data.price_change_6h,
918                    token_data.price_change_24h,
919                    now_ts,
920                );
921                let vol_hist = Self::generate_synthetic_volume_history(
922                    token_data.volume_24h,
923                    token_data.volume_6h,
924                    token_data.volume_1h,
925                    now_ts,
926                );
927                (price_hist, vol_hist, 0)
928            };
929
930        Self {
931            token_address: token_data.address.clone(),
932            symbol: token_data.symbol.clone(),
933            name: token_data.name.clone(),
934            chain: chain.to_string(),
935            price_history,
936            volume_history,
937            real_data_count,
938            current_price: token_data.price_usd,
939            price_change_24h: token_data.price_change_24h,
940            price_change_6h: token_data.price_change_6h,
941            price_change_1h: token_data.price_change_1h,
942            price_change_5m: token_data.price_change_5m,
943            last_price_change_at: now_ts, // Initialize to current time
944            previous_price: token_data.price_usd,
945            buys_24h: token_data.total_buys_24h,
946            sells_24h: token_data.total_sells_24h,
947            liquidity_usd: token_data.liquidity_usd,
948            volume_24h: token_data.volume_24h,
949            market_cap: token_data.market_cap,
950            fdv: token_data.fdv,
951            last_update: now,
952            refresh_rate: Duration::from_secs(DEFAULT_REFRESH_SECS),
953            paused: false,
954            log_messages: VecDeque::with_capacity(10),
955            log_list_state: ListState::default(),
956            error_message: None,
957            time_period: TimePeriod::Hour1, // Default to 1 hour view
958            chart_mode: ChartMode::Line,    // Default to line chart
959            scale_mode: ScaleMode::Linear,  // Default to linear scale
960            color_scheme: ColorScheme::GreenRed, // Default color scheme
961            holder_count: None,
962            liquidity_pairs: Vec::new(),
963            order_book: None,
964            recent_trades: VecDeque::new(),
965            dex_pairs: token_data.pairs.clone(),
966            websites: token_data.websites.clone(),
967            socials: token_data
968                .socials
969                .iter()
970                .map(|s| (s.platform.clone(), s.url.clone()))
971                .collect(),
972            earliest_pair_created_at: token_data.earliest_pair_created_at,
973            dexscreener_url: token_data.dexscreener_url.clone(),
974            holder_fetch_counter: 0,
975            start_timestamp: now_ts as i64,
976            layout: LayoutPreset::Dashboard,
977            widgets: WidgetVisibility::default(),
978            auto_layout: true,
979            widget_toggle_mode: false,
980            // Phase 7: Alerts
981            alerts: AlertConfig::default(),
982            active_alerts: Vec::new(),
983            alert_flash_until: None,
984            // Phase 8: Export
985            export_active: false,
986            export_path: None,
987            volume_avg: token_data.volume_24h,
988            // Phase 9: Auto-Pause
989            auto_pause_on_input: false,
990            last_input_at: now,
991            auto_pause_timeout: Duration::from_secs(3),
992            // Exchange OHLC
993            exchange_ohlc: Vec::new(),
994            venue_pair: None,
995        }
996    }
997
998    /// Applies monitor config settings to this state.
999    pub fn apply_config(&mut self, config: &MonitorConfig) {
1000        self.layout = config.layout;
1001        self.widgets = config.widgets.clone();
1002        self.refresh_rate = Duration::from_secs(config.refresh_seconds);
1003        self.scale_mode = config.scale;
1004        self.color_scheme = config.color_scheme;
1005        self.alerts = config.alerts.clone();
1006        self.auto_pause_on_input = config.auto_pause_on_input;
1007    }
1008
1009    /// Toggles between line and candlestick chart modes.
1010    /// Returns the current color palette based on the active color scheme.
1011    pub fn palette(&self) -> ColorPalette {
1012        self.color_scheme.palette()
1013    }
1014
1015    pub fn toggle_chart_mode(&mut self) {
1016        self.chart_mode = self.chart_mode.next();
1017        self.log(format!("Chart mode: {}", self.chart_mode.label()));
1018    }
1019
1020    /// Returns the path to the cache file for a token.
1021    fn cache_path(token_address: &str, chain: &str) -> PathBuf {
1022        let mut path = std::env::temp_dir();
1023        // Create a safe filename from address (first 16 chars) and chain
1024        let safe_addr = token_address
1025            .chars()
1026            .filter(|c| c.is_alphanumeric())
1027            .take(16)
1028            .collect::<String>()
1029            .to_lowercase();
1030        path.push(format!("{}{}_{}.json", CACHE_FILE_PREFIX, chain, safe_addr));
1031        path
1032    }
1033
1034    /// Loads cached monitor data from disk.
1035    fn load_cache(token_address: &str, chain: &str) -> Option<CachedMonitorData> {
1036        let path = Self::cache_path(token_address, chain);
1037        if !path.exists() {
1038            return None;
1039        }
1040
1041        match fs::read_to_string(&path) {
1042            Ok(contents) => {
1043                match serde_json::from_str::<CachedMonitorData>(&contents) {
1044                    Ok(cached) => {
1045                        // Verify this is for the same token
1046                        if cached.token_address.to_lowercase() == token_address.to_lowercase()
1047                            && cached.chain.to_lowercase() == chain.to_lowercase()
1048                        {
1049                            Some(cached)
1050                        } else {
1051                            None
1052                        }
1053                    }
1054                    Err(_) => None,
1055                }
1056            }
1057            Err(_) => None,
1058        }
1059    }
1060
1061    /// Saves monitor data to cache file.
1062    pub fn save_cache(&self) {
1063        let cached = CachedMonitorData {
1064            token_address: self.token_address.clone(),
1065            chain: self.chain.clone(),
1066            price_history: self.price_history.iter().copied().collect(),
1067            volume_history: self.volume_history.iter().copied().collect(),
1068            saved_at: chrono::Utc::now().timestamp() as f64,
1069        };
1070
1071        let path = Self::cache_path(&self.token_address, &self.chain);
1072        if let Ok(json) = serde_json::to_string(&cached) {
1073            let _ = fs::write(&path, json);
1074        }
1075    }
1076
1077    /// Generates synthetic price history from percentage changes.
1078    /// All generated points are marked as synthetic (is_real = false).
1079    fn generate_synthetic_price_history(
1080        current_price: f64,
1081        change_1h: f64,
1082        change_6h: f64,
1083        change_24h: f64,
1084        now_ts: f64,
1085    ) -> VecDeque<DataPoint> {
1086        let mut history = VecDeque::with_capacity(50);
1087
1088        // Calculate prices at known points (working backwards from current)
1089        let price_1h_ago = current_price / (1.0 + change_1h / 100.0);
1090        let price_6h_ago = current_price / (1.0 + change_6h / 100.0);
1091        let price_24h_ago = current_price / (1.0 + change_24h / 100.0);
1092
1093        // Generate points: 24h ago, 12h ago, 6h ago, 3h ago, 1h ago, 30m ago, now
1094        let points = [
1095            (now_ts - 24.0 * 3600.0, price_24h_ago),
1096            (now_ts - 12.0 * 3600.0, (price_24h_ago + price_6h_ago) / 2.0),
1097            (now_ts - 6.0 * 3600.0, price_6h_ago),
1098            (now_ts - 3.0 * 3600.0, (price_6h_ago + price_1h_ago) / 2.0),
1099            (now_ts - 1.0 * 3600.0, price_1h_ago),
1100            (now_ts - 0.5 * 3600.0, (price_1h_ago + current_price) / 2.0),
1101            (now_ts, current_price),
1102        ];
1103
1104        // Interpolate to create more points for smoother charts
1105        for i in 0..points.len() - 1 {
1106            let (t1, p1) = points[i];
1107            let (t2, p2) = points[i + 1];
1108            let steps = 4; // Number of interpolated points between each pair
1109
1110            for j in 0..steps {
1111                let frac = j as f64 / steps as f64;
1112                let t = t1 + (t2 - t1) * frac;
1113                let p = p1 + (p2 - p1) * frac;
1114                history.push_back(DataPoint {
1115                    timestamp: t,
1116                    value: p,
1117                    is_real: false, // Synthetic data
1118                });
1119            }
1120        }
1121        // Add the final point (also synthetic since it's estimated)
1122        history.push_back(DataPoint {
1123            timestamp: points[points.len() - 1].0,
1124            value: points[points.len() - 1].1,
1125            is_real: false,
1126        });
1127
1128        history
1129    }
1130
1131    /// Generates synthetic volume history from known data points.
1132    /// All generated points are marked as synthetic (is_real = false).
1133    fn generate_synthetic_volume_history(
1134        volume_24h: f64,
1135        volume_6h: f64,
1136        volume_1h: f64,
1137        now_ts: f64,
1138    ) -> VecDeque<DataPoint> {
1139        let mut history = VecDeque::with_capacity(24);
1140
1141        // Create hourly volume estimates
1142        let hourly_avg = volume_24h / 24.0;
1143
1144        for i in 0..24 {
1145            let hours_ago = 24 - i;
1146            let ts = now_ts - (hours_ago as f64) * 3600.0;
1147
1148            // Use more accurate data for recent hours
1149            let volume = if hours_ago <= 1 {
1150                volume_1h
1151            } else if hours_ago <= 6 {
1152                volume_6h / 6.0
1153            } else {
1154                // Estimate with some variation
1155                hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
1156            };
1157
1158            history.push_back(DataPoint {
1159                timestamp: ts,
1160                value: volume,
1161                is_real: false, // Synthetic data
1162            });
1163        }
1164
1165        history
1166    }
1167
1168    /// Generates a multi-level synthetic order book from DEX pair data.
1169    ///
1170    /// Aggregates liquidity across all pairs and distributes it into
1171    /// realistic bid/ask levels around the current mid price. Levels are
1172    /// spaced logarithmically so near-mid levels are denser.
1173    fn generate_synthetic_order_book(
1174        pairs: &[DexPair],
1175        symbol: &str,
1176        price: f64,
1177        total_liquidity: f64,
1178    ) -> Option<OrderBook> {
1179        if price <= 0.0 || total_liquidity <= 0.0 {
1180            return None;
1181        }
1182
1183        // Spread is tighter for more liquid markets
1184        let base_spread_bps = if total_liquidity > 1_000_000.0 {
1185            5.0 // 0.05%
1186        } else if total_liquidity > 100_000.0 {
1187            15.0 // 0.15%
1188        } else {
1189            50.0 // 0.50%
1190        };
1191
1192        let half_spread = price * base_spread_bps / 10_000.0;
1193        let half_liq = total_liquidity / 2.0;
1194        let num_levels: usize = 15;
1195
1196        // Generate ask levels (ascending from mid + half_spread)
1197        let mut asks = Vec::with_capacity(num_levels);
1198        for i in 0..num_levels {
1199            // Exponential spacing: tighter near the mid, wider further out
1200            let offset_pct = (1.0 + i as f64 * 0.3).powf(1.4) * 0.001;
1201            let ask_price = price + half_spread + price * offset_pct;
1202            // Liquidity decreases further from mid (exponential decay)
1203            let weight = (-1.5 * i as f64 / num_levels as f64).exp();
1204            let level_liq = half_liq * weight / num_levels as f64 * 2.5;
1205            let quantity = level_liq / ask_price;
1206            if quantity > 0.0 {
1207                asks.push(OrderBookLevel {
1208                    price: ask_price,
1209                    quantity,
1210                });
1211            }
1212        }
1213
1214        // Generate bid levels (descending from mid - half_spread)
1215        let mut bids = Vec::with_capacity(num_levels);
1216        for i in 0..num_levels {
1217            let offset_pct = (1.0 + i as f64 * 0.3).powf(1.4) * 0.001;
1218            let bid_price = price - half_spread - price * offset_pct;
1219            if bid_price <= 0.0 {
1220                break;
1221            }
1222            let weight = (-1.5 * i as f64 / num_levels as f64).exp();
1223            let level_liq = half_liq * weight / num_levels as f64 * 2.5;
1224            let quantity = level_liq / bid_price;
1225            if quantity > 0.0 {
1226                bids.push(OrderBookLevel {
1227                    price: bid_price,
1228                    quantity,
1229                });
1230            }
1231        }
1232
1233        // Find best quote token from pairs for the pair label
1234        let quote = pairs
1235            .first()
1236            .map(|p| p.quote_token.as_str())
1237            .unwrap_or("USD");
1238
1239        Some(OrderBook {
1240            pair: format!("{}/{}", symbol, quote),
1241            bids,
1242            asks,
1243        })
1244    }
1245
1246    /// Updates the state with new token data.
1247    /// New data points are marked as real (is_real = true).
1248    pub fn update(&mut self, token_data: &DexTokenData) {
1249        let now_ts = chrono::Utc::now().timestamp() as f64;
1250
1251        // Add new REAL data points
1252        self.price_history.push_back(DataPoint {
1253            timestamp: now_ts,
1254            value: token_data.price_usd,
1255            is_real: true,
1256        });
1257        self.volume_history.push_back(DataPoint {
1258            timestamp: now_ts,
1259            value: token_data.volume_24h,
1260            is_real: true,
1261        });
1262        self.real_data_count += 1;
1263
1264        // Trim data points older than 24 hours
1265        let cutoff = now_ts - MAX_DATA_AGE_SECS;
1266
1267        while let Some(point) = self.price_history.front() {
1268            if point.timestamp < cutoff {
1269                self.price_history.pop_front();
1270            } else {
1271                break;
1272            }
1273        }
1274        while let Some(point) = self.volume_history.front() {
1275            if point.timestamp < cutoff {
1276                self.volume_history.pop_front();
1277            } else {
1278                break;
1279            }
1280        }
1281
1282        // Track when price actually changes (using 8 decimal precision for stablecoins)
1283        let price_changed = (self.previous_price - token_data.price_usd).abs() > 0.00000001;
1284        if price_changed {
1285            self.last_price_change_at = now_ts;
1286            self.previous_price = token_data.price_usd;
1287        }
1288
1289        // Update current values
1290        self.current_price = token_data.price_usd;
1291        self.price_change_24h = token_data.price_change_24h;
1292        self.price_change_6h = token_data.price_change_6h;
1293        self.price_change_1h = token_data.price_change_1h;
1294        self.price_change_5m = token_data.price_change_5m;
1295        self.buys_24h = token_data.total_buys_24h;
1296        self.sells_24h = token_data.total_sells_24h;
1297        self.liquidity_usd = token_data.liquidity_usd;
1298        self.volume_24h = token_data.volume_24h;
1299        self.market_cap = token_data.market_cap;
1300        self.fdv = token_data.fdv;
1301
1302        // Extract per-pair liquidity data
1303        self.liquidity_pairs = token_data
1304            .pairs
1305            .iter()
1306            .map(|p| {
1307                let label = format!("{}/{} ({})", p.base_token, p.quote_token, p.dex_name);
1308                (label, p.liquidity_usd)
1309            })
1310            .collect();
1311
1312        // Update DEX pairs and generate synthetic order book
1313        self.dex_pairs = token_data.pairs.clone();
1314        self.order_book = Self::generate_synthetic_order_book(
1315            &token_data.pairs,
1316            &token_data.symbol,
1317            token_data.price_usd,
1318            token_data.liquidity_usd,
1319        );
1320
1321        // Generate synthetic trade from price movement
1322        if token_data.price_usd > 0.0 {
1323            let side = if price_changed && token_data.price_usd > self.current_price {
1324                TradeSide::Buy
1325            } else if price_changed {
1326                TradeSide::Sell
1327            } else {
1328                // No change — alternate based on buy/sell ratio
1329                if token_data.total_buys_24h >= token_data.total_sells_24h {
1330                    TradeSide::Buy
1331                } else {
1332                    TradeSide::Sell
1333                }
1334            };
1335            let ts_ms = (now_ts * 1000.0) as u64;
1336            // Synthetic quantity based on recent volume
1337            let qty = if token_data.volume_24h > 0.0 && token_data.price_usd > 0.0 {
1338                // Rough: daily volume / 86400 updates * refresh_rate gives per-update volume
1339                let per_update_vol =
1340                    token_data.volume_24h / 86400.0 * self.refresh_rate.as_secs_f64();
1341                per_update_vol / token_data.price_usd
1342            } else {
1343                1.0
1344            };
1345            self.recent_trades.push_back(Trade {
1346                price: token_data.price_usd,
1347                quantity: qty,
1348                quote_quantity: Some(qty * token_data.price_usd),
1349                timestamp_ms: ts_ms,
1350                side,
1351                id: None,
1352            });
1353            // Keep at most 200 trades
1354            while self.recent_trades.len() > 200 {
1355                self.recent_trades.pop_front();
1356            }
1357        }
1358
1359        // Update metadata
1360        self.websites = token_data.websites.clone();
1361        self.socials = token_data
1362            .socials
1363            .iter()
1364            .map(|s| (s.platform.clone(), s.url.clone()))
1365            .collect();
1366        self.earliest_pair_created_at = token_data.earliest_pair_created_at;
1367        self.dexscreener_url = token_data.dexscreener_url.clone();
1368
1369        self.last_update = Instant::now();
1370        self.error_message = None;
1371
1372        // ── Alert checks ──
1373        self.check_alerts(token_data);
1374
1375        // ── CSV export ──
1376        if self.export_active {
1377            self.write_export_row();
1378        }
1379
1380        // ── Volume average for spike detection ──
1381        // Exponential moving average: 10% weight on new value
1382        self.volume_avg = self.volume_avg * 0.9 + token_data.volume_24h * 0.1;
1383
1384        self.log(format!("Updated: ${:.6}", token_data.price_usd));
1385
1386        // Periodically save to cache (every 60 updates, ~5 minutes at 5s refresh)
1387        if self.real_data_count.is_multiple_of(60) {
1388            self.save_cache();
1389        }
1390    }
1391
1392    /// Checks alert thresholds and updates active_alerts.
1393    fn check_alerts(&mut self, token_data: &DexTokenData) {
1394        self.active_alerts.clear();
1395
1396        // Price min alert
1397        if let Some(min) = self.alerts.price_min
1398            && self.current_price < min
1399        {
1400            self.active_alerts.push(ActiveAlert {
1401                message: format!("⚠ Price ${:.6} below min ${:.6}", self.current_price, min),
1402                triggered_at: Instant::now(),
1403            });
1404        }
1405
1406        // Price max alert
1407        if let Some(max) = self.alerts.price_max
1408            && self.current_price > max
1409        {
1410            self.active_alerts.push(ActiveAlert {
1411                message: format!("⚠ Price ${:.6} above max ${:.6}", self.current_price, max),
1412                triggered_at: Instant::now(),
1413            });
1414        }
1415
1416        // Volume spike alert
1417        if let Some(threshold_pct) = self.alerts.volume_spike_threshold_pct
1418            && self.volume_avg > 0.0
1419        {
1420            let spike_pct = ((token_data.volume_24h - self.volume_avg) / self.volume_avg) * 100.0;
1421            if spike_pct > threshold_pct {
1422                self.active_alerts.push(ActiveAlert {
1423                    message: format!("⚠ Volume spike: +{:.1}% vs avg", spike_pct),
1424                    triggered_at: Instant::now(),
1425                });
1426            }
1427        }
1428
1429        // Whale detection — estimate from buy/sell changes
1430        if let Some(whale_min) = self.alerts.whale_min_usd {
1431            // Approximate single-transaction size from volume/tx count
1432            let total_txs = (token_data.total_buys_24h + token_data.total_sells_24h) as f64;
1433            if total_txs > 0.0 && token_data.volume_24h / total_txs >= whale_min {
1434                let avg_tx_size = token_data.volume_24h / total_txs;
1435                self.active_alerts.push(ActiveAlert {
1436                    message: format!(
1437                        "🐋 Avg tx size {} ≥ whale threshold {}",
1438                        crate::display::format_usd(avg_tx_size),
1439                        crate::display::format_usd(whale_min)
1440                    ),
1441                    triggered_at: Instant::now(),
1442                });
1443            }
1444        }
1445
1446        // Set flash timer if any alerts are active
1447        if !self.active_alerts.is_empty() {
1448            self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
1449        }
1450    }
1451
1452    /// Writes a single CSV row to the export file.
1453    fn write_export_row(&mut self) {
1454        if let Some(ref path) = self.export_path {
1455            // Open file in append mode
1456            if let Ok(file) = fs::OpenOptions::new().append(true).open(path) {
1457                let mut writer = BufWriter::new(file);
1458                let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
1459                let market_cap_str = self
1460                    .market_cap
1461                    .map(|mc| format!("{:.2}", mc))
1462                    .unwrap_or_default();
1463                let row = format!(
1464                    "{},{:.8},{:.2},{:.2},{},{},{}\n",
1465                    timestamp,
1466                    self.current_price,
1467                    self.volume_24h,
1468                    self.liquidity_usd,
1469                    self.buys_24h,
1470                    self.sells_24h,
1471                    market_cap_str,
1472                );
1473                let _ = writer.write_all(row.as_bytes());
1474            }
1475        }
1476    }
1477
1478    /// Starts CSV export: creates the file and writes the header.
1479    pub fn start_export(&mut self) {
1480        let base_dir = PathBuf::from("./scope-exports");
1481        let _ = fs::create_dir_all(&base_dir);
1482        let date_str = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
1483        let filename = format!("{}_{}.csv", self.symbol, date_str);
1484        let path = base_dir.join(filename);
1485
1486        // Write CSV header
1487        if let Ok(mut file) = fs::File::create(&path) {
1488            let header =
1489                "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
1490            let _ = file.write_all(header.as_bytes());
1491        }
1492
1493        self.export_path = Some(path.clone());
1494        self.export_active = true;
1495        self.log(format!("Export started: {}", path.display()));
1496    }
1497
1498    /// Stops CSV export and closes the file.
1499    pub fn stop_export(&mut self) {
1500        if let Some(ref path) = self.export_path {
1501            self.log(format!("Export saved: {}", path.display()));
1502        }
1503        self.export_active = false;
1504        self.export_path = None;
1505    }
1506
1507    /// Toggles CSV export on/off.
1508    pub fn toggle_export(&mut self) {
1509        if self.export_active {
1510            self.stop_export();
1511        } else {
1512            self.start_export();
1513        }
1514    }
1515
1516    /// Returns data points filtered by the current time period.
1517    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
1518    pub fn get_price_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1519        let now_ts = chrono::Utc::now().timestamp() as f64;
1520        let cutoff = now_ts - self.time_period.duration_secs() as f64;
1521
1522        let filtered: Vec<&DataPoint> = self
1523            .price_history
1524            .iter()
1525            .filter(|p| p.timestamp >= cutoff)
1526            .collect();
1527
1528        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1529        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1530
1531        (data, is_real)
1532    }
1533
1534    /// Returns volume data filtered by the current time period.
1535    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
1536    pub fn get_volume_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1537        let now_ts = chrono::Utc::now().timestamp() as f64;
1538        let cutoff = now_ts - self.time_period.duration_secs() as f64;
1539
1540        let filtered: Vec<&DataPoint> = self
1541            .volume_history
1542            .iter()
1543            .filter(|p| p.timestamp >= cutoff)
1544            .collect();
1545
1546        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1547        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1548
1549        (data, is_real)
1550    }
1551
1552    /// Returns count of synthetic vs real data points in the current view.
1553    pub fn data_stats(&self) -> (usize, usize) {
1554        let now_ts = chrono::Utc::now().timestamp() as f64;
1555        let cutoff = now_ts - self.time_period.duration_secs() as f64;
1556
1557        let (synthetic, real) = self
1558            .price_history
1559            .iter()
1560            .filter(|p| p.timestamp >= cutoff)
1561            .fold(
1562                (0, 0),
1563                |(s, r), p| {
1564                    if p.is_real { (s, r + 1) } else { (s + 1, r) }
1565                },
1566            );
1567
1568        (synthetic, real)
1569    }
1570
1571    /// Estimates memory usage of stored data in bytes.
1572    pub fn memory_usage(&self) -> usize {
1573        // DataPoint is 24 bytes (f64 + f64 + bool + padding)
1574        let point_size = std::mem::size_of::<DataPoint>();
1575        (self.price_history.len() + self.volume_history.len()) * point_size
1576    }
1577
1578    /// Generates OHLC candles from price history for the current time period.
1579    ///
1580    /// The candle duration is automatically determined based on the selected time period:
1581    /// - 1m view: 5-second candles
1582    /// - 5m view: 15-second candles
1583    /// - 15m view: 1-minute candles
1584    /// - 1h view: 5-minute candles
1585    /// - 4h view: 15-minute candles
1586    /// - 1d view: 1-hour candles
1587    pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
1588        // Prefer real exchange OHLC candles when available
1589        if !self.exchange_ohlc.is_empty() {
1590            return self.exchange_ohlc.clone();
1591        }
1592
1593        let (data, _) = self.get_price_data_for_period();
1594
1595        if data.is_empty() {
1596            return vec![];
1597        }
1598
1599        // Determine candle duration based on time period
1600        let candle_duration_secs = match self.time_period {
1601            TimePeriod::Min1 => 5.0,    // 5-second candles
1602            TimePeriod::Min5 => 15.0,   // 15-second candles
1603            TimePeriod::Min15 => 60.0,  // 1-minute candles
1604            TimePeriod::Hour1 => 300.0, // 5-minute candles
1605            TimePeriod::Hour4 => 900.0, // 15-minute candles
1606            TimePeriod::Day1 => 3600.0, // 1-hour candles
1607        };
1608
1609        let mut candles: Vec<OhlcCandle> = Vec::new();
1610
1611        for (timestamp, price) in data {
1612            // Determine which candle this point belongs to
1613            let candle_start = (timestamp / candle_duration_secs).floor() * candle_duration_secs;
1614
1615            if let Some(last_candle) = candles.last_mut() {
1616                if (last_candle.timestamp - candle_start).abs() < 0.001 {
1617                    // Same candle, update it
1618                    last_candle.update(price);
1619                } else {
1620                    // New candle
1621                    candles.push(OhlcCandle::new(candle_start, price));
1622                }
1623            } else {
1624                // First candle
1625                candles.push(OhlcCandle::new(candle_start, price));
1626            }
1627        }
1628
1629        candles
1630    }
1631
1632    /// Cycles to the next time period.
1633    pub fn cycle_time_period(&mut self) {
1634        self.time_period = self.time_period.next();
1635        self.log(format!("Time period: {}", self.time_period.label()));
1636    }
1637
1638    /// Sets a specific time period.
1639    pub fn set_time_period(&mut self, period: TimePeriod) {
1640        self.time_period = period;
1641        self.log(format!("Time period: {}", period.label()));
1642    }
1643
1644    /// Logs a message to the log panel.
1645    fn log(&mut self, message: String) {
1646        let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
1647        self.log_messages
1648            .push_back(format!("[{}] {}", timestamp, message));
1649        while self.log_messages.len() > 10 {
1650            self.log_messages.pop_front();
1651        }
1652    }
1653
1654    /// Returns whether a refresh is needed.
1655    /// Respects manual pause, auto-pause on input, and the refresh interval.
1656    pub fn should_refresh(&self) -> bool {
1657        if self.paused {
1658            return false;
1659        }
1660        // Auto-pause: if the user is actively interacting, defer refresh
1661        if self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout {
1662            return false;
1663        }
1664        self.last_update.elapsed() >= self.refresh_rate
1665    }
1666
1667    /// Returns true if auto-pause is currently suppressing refreshes.
1668    pub fn is_auto_paused(&self) -> bool {
1669        self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout
1670    }
1671
1672    /// Toggles pause state.
1673    pub fn toggle_pause(&mut self) {
1674        self.paused = !self.paused;
1675        self.log(if self.paused {
1676            "Paused".to_string()
1677        } else {
1678            "Resumed".to_string()
1679        });
1680    }
1681
1682    /// Forces an immediate refresh.
1683    pub fn force_refresh(&mut self) {
1684        self.paused = false;
1685        self.last_update = Instant::now() - self.refresh_rate;
1686    }
1687
1688    /// Increases refresh interval (slower updates).
1689    pub fn slower_refresh(&mut self) {
1690        let current_secs = self.refresh_rate.as_secs();
1691        let new_secs = (current_secs + 5).min(MAX_REFRESH_SECS);
1692        self.refresh_rate = Duration::from_secs(new_secs);
1693        self.log(format!("Refresh rate: {}s", new_secs));
1694    }
1695
1696    /// Decreases refresh interval (faster updates).
1697    pub fn faster_refresh(&mut self) {
1698        let current_secs = self.refresh_rate.as_secs();
1699        let new_secs = current_secs.saturating_sub(5).max(MIN_REFRESH_SECS);
1700        self.refresh_rate = Duration::from_secs(new_secs);
1701        self.log(format!("Refresh rate: {}s", new_secs));
1702    }
1703
1704    /// Scrolls the activity log down (newer messages).
1705    pub fn scroll_log_down(&mut self) {
1706        let len = self.log_messages.len();
1707        if len == 0 {
1708            return;
1709        }
1710        let i = self
1711            .log_list_state
1712            .selected()
1713            .map_or(0, |i| if i + 1 < len { i + 1 } else { i });
1714        self.log_list_state.select(Some(i));
1715    }
1716
1717    /// Scrolls the activity log up (older messages).
1718    pub fn scroll_log_up(&mut self) {
1719        let i = self
1720            .log_list_state
1721            .selected()
1722            .map_or(0, |i| i.saturating_sub(1));
1723        self.log_list_state.select(Some(i));
1724    }
1725
1726    /// Returns the current refresh rate in seconds.
1727    pub fn refresh_rate_secs(&self) -> u64 {
1728        self.refresh_rate.as_secs()
1729    }
1730
1731    /// Returns the buy/sell ratio as a percentage (0.0 to 1.0).
1732    pub fn buy_ratio(&self) -> f64 {
1733        let total = self.buys_24h + self.sells_24h;
1734        if total == 0 {
1735            0.5
1736        } else {
1737            self.buys_24h as f64 / total as f64
1738        }
1739    }
1740}
1741
1742/// Main monitor application, generic over terminal backend for testability.
1743///
1744/// The default type parameter (`CrosstermBackend<Stdout>`) is used in production.
1745/// Tests use `TestBackend` to avoid real terminal I/O.
1746pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
1747{
1748    /// Terminal backend.
1749    terminal: ratatui::Terminal<B>,
1750
1751    /// Monitor state.
1752    state: MonitorState,
1753
1754    /// DEX client for fetching data.
1755    dex_client: Box<dyn DexDataSource>,
1756
1757    /// Optional chain client for on-chain data (holder count, etc.).
1758    chain_client: Option<Box<dyn ChainClient>>,
1759
1760    /// Optional exchange client for real OHLC data.
1761    exchange_client: Option<crate::market::ExchangeClient>,
1762
1763    /// Whether to exit the application.
1764    should_exit: bool,
1765
1766    /// Whether this app owns the real terminal (and should restore it on drop).
1767    /// False for test instances using `TestBackend`.
1768    owns_terminal: bool,
1769}
1770
1771/// Production constructor and terminal-specific methods.
1772impl MonitorApp {
1773    /// Creates a new monitor application with a real terminal and live DEX client.
1774    pub fn new(
1775        initial_data: DexTokenData,
1776        chain: &str,
1777        monitor_config: &MonitorConfig,
1778        chain_client: Option<Box<dyn ChainClient>>,
1779        exchange_client: Option<crate::market::ExchangeClient>,
1780    ) -> Result<Self> {
1781        // Setup terminal using ratatui's simplified init
1782        let terminal = ratatui::init();
1783        // Enable mouse capture (not handled by ratatui::init)
1784        execute!(io::stdout(), EnableMouseCapture)
1785            .map_err(|e| ScopeError::Chain(format!("Failed to enable mouse capture: {}", e)))?;
1786
1787        let mut state = MonitorState::new(&initial_data, chain);
1788        state.apply_config(monitor_config);
1789
1790        // If we have an exchange client, set up the venue pair for OHLC queries
1791        if let Some(ref ex) = exchange_client {
1792            let pair = ex.format_pair(&initial_data.symbol);
1793            state.venue_pair = Some(pair);
1794        }
1795
1796        Ok(Self {
1797            terminal,
1798            state,
1799            dex_client: Box::new(DexClient::new()),
1800            chain_client,
1801            exchange_client,
1802            should_exit: false,
1803            owns_terminal: true,
1804        })
1805    }
1806
1807    /// Runs the main event loop using async event stream.
1808    pub async fn run(&mut self) -> Result<()> {
1809        use futures::StreamExt;
1810
1811        let mut event_stream = crossterm::event::EventStream::new();
1812
1813        loop {
1814            // Render UI
1815            self.terminal.draw(|f| ui(f, &mut self.state))?;
1816
1817            // Calculate how long until next refresh
1818            let refresh_delay = if self.state.paused {
1819                Duration::from_millis(200) // Just check for events while paused
1820            } else {
1821                let elapsed = self.state.last_update.elapsed();
1822                self.state.refresh_rate.saturating_sub(elapsed)
1823            };
1824
1825            // Wait for either an event or the refresh timer
1826            tokio::select! {
1827                maybe_event = event_stream.next() => {
1828                    match maybe_event {
1829                        Some(Ok(Event::Key(key))) => {
1830                            self.handle_key_event(key);
1831                        }
1832                        Some(Ok(Event::Resize(_, _))) => {
1833                            // Terminal resized — ui() will pick up new size on next draw
1834                        }
1835                        _ => {}
1836                    }
1837                }
1838                _ = tokio::time::sleep(refresh_delay) => {
1839                    // Timer expired — check if refresh is needed
1840                }
1841            }
1842
1843            if self.should_exit {
1844                break;
1845            }
1846
1847            // Check if refresh needed
1848            if self.state.should_refresh() {
1849                self.fetch_data().await;
1850            }
1851        }
1852
1853        Ok(())
1854    }
1855}
1856
1857impl<B: ratatui::backend::Backend> Drop for MonitorApp<B> {
1858    fn drop(&mut self) {
1859        if self.owns_terminal {
1860            let _ = execute!(io::stdout(), DisableMouseCapture);
1861            ratatui::restore();
1862        }
1863    }
1864}
1865
1866/// Methods that work with any terminal backend (production or test).
1867/// Includes cleanup, key handling, and data fetching.
1868impl<B: ratatui::backend::Backend> MonitorApp<B> {
1869    /// Cleans up terminal state. Only performs real terminal restore when
1870    /// this app owns the terminal (production mode).
1871    pub fn cleanup(&mut self) -> Result<()> {
1872        // Save cache before exiting
1873        self.state.save_cache();
1874
1875        if self.owns_terminal {
1876            // Disable mouse capture (not handled by ratatui::restore)
1877            let _ = execute!(io::stdout(), DisableMouseCapture);
1878            // Restore terminal using ratatui's simplified cleanup
1879            ratatui::restore();
1880        }
1881        Ok(())
1882    }
1883
1884    /// Handles a single key event, updating state accordingly.
1885    /// Extracted from the event loop for testability.
1886    fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
1887        // Track last input time for auto-pause
1888        self.state.last_input_at = Instant::now();
1889
1890        // Widget toggle mode: waiting for digit 1-5
1891        if self.state.widget_toggle_mode {
1892            self.state.widget_toggle_mode = false;
1893            if let KeyCode::Char(c @ '1'..='5') = key.code {
1894                let idx = (c as u8 - b'0') as usize;
1895                self.state.widgets.toggle_by_index(idx);
1896                return;
1897            }
1898            // Any other key cancels the mode and falls through
1899        }
1900
1901        match key.code {
1902            KeyCode::Char('q') | KeyCode::Esc => {
1903                // Stop export before quitting if active
1904                if self.state.export_active {
1905                    self.state.stop_export();
1906                }
1907                self.should_exit = true;
1908            }
1909            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1910                if self.state.export_active {
1911                    self.state.stop_export();
1912                }
1913                self.should_exit = true;
1914            }
1915            KeyCode::Char('r') => {
1916                self.state.force_refresh();
1917            }
1918            // Shift+P toggles auto-pause on input
1919            KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
1920                self.state.auto_pause_on_input = !self.state.auto_pause_on_input;
1921                self.state.log(format!(
1922                    "Auto-pause: {}",
1923                    if self.state.auto_pause_on_input {
1924                        "ON"
1925                    } else {
1926                        "OFF"
1927                    }
1928                ));
1929            }
1930            KeyCode::Char('p') | KeyCode::Char(' ') => {
1931                self.state.toggle_pause();
1932            }
1933            // Toggle CSV export
1934            KeyCode::Char('e') => {
1935                self.state.toggle_export();
1936            }
1937            // Increase refresh interval (slower)
1938            KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
1939                self.state.slower_refresh();
1940            }
1941            // Decrease refresh interval (faster)
1942            KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
1943                self.state.faster_refresh();
1944            }
1945            // Time period selection (1=1m, 2=5m, 3=15m, 4=1h, 5=4h, 6=1d)
1946            KeyCode::Char('1') => {
1947                self.state.set_time_period(TimePeriod::Min1);
1948            }
1949            KeyCode::Char('2') => {
1950                self.state.set_time_period(TimePeriod::Min5);
1951            }
1952            KeyCode::Char('3') => {
1953                self.state.set_time_period(TimePeriod::Min15);
1954            }
1955            KeyCode::Char('4') => {
1956                self.state.set_time_period(TimePeriod::Hour1);
1957            }
1958            KeyCode::Char('5') => {
1959                self.state.set_time_period(TimePeriod::Hour4);
1960            }
1961            KeyCode::Char('6') => {
1962                self.state.set_time_period(TimePeriod::Day1);
1963            }
1964            KeyCode::Char('t') | KeyCode::Tab => {
1965                self.state.cycle_time_period();
1966            }
1967            // Toggle chart mode (line/candlestick/volume-profile)
1968            KeyCode::Char('c') => {
1969                self.state.toggle_chart_mode();
1970            }
1971            // Toggle scale mode (linear/log)
1972            KeyCode::Char('s') => {
1973                self.state.scale_mode = self.state.scale_mode.toggle();
1974                self.state
1975                    .log(format!("Scale: {}", self.state.scale_mode.label()));
1976            }
1977            // Cycle color scheme
1978            KeyCode::Char('/') => {
1979                self.state.color_scheme = self.state.color_scheme.next();
1980                self.state
1981                    .log(format!("Colors: {}", self.state.color_scheme.label()));
1982            }
1983            // Scroll activity log
1984            KeyCode::Char('j') | KeyCode::Down => {
1985                self.state.scroll_log_down();
1986            }
1987            KeyCode::Char('k') | KeyCode::Up => {
1988                self.state.scroll_log_up();
1989            }
1990            // Layout cycling
1991            KeyCode::Char('l') => {
1992                self.state.layout = self.state.layout.next();
1993                self.state.auto_layout = false;
1994            }
1995            KeyCode::Char('h') => {
1996                self.state.layout = self.state.layout.prev();
1997                self.state.auto_layout = false;
1998            }
1999            // Widget toggle mode
2000            KeyCode::Char('w') => {
2001                self.state.widget_toggle_mode = true;
2002            }
2003            // Re-enable auto layout
2004            KeyCode::Char('a') => {
2005                self.state.auto_layout = true;
2006            }
2007            _ => {}
2008        }
2009    }
2010
2011    /// Fetches new data from the API.
2012    async fn fetch_data(&mut self) {
2013        match self
2014            .dex_client
2015            .get_token_data(&self.state.chain, &self.state.token_address)
2016            .await
2017        {
2018            Ok(data) => {
2019                self.state.update(&data);
2020            }
2021            Err(e) => {
2022                self.state.error_message = Some(format!("API Error: {}", e));
2023                self.state.last_update = Instant::now(); // Prevent rapid retries
2024            }
2025        }
2026
2027        // Fetch OHLC candles from exchange venue (if configured)
2028        if let (Some(ex), Some(pair)) = (&self.exchange_client, &self.state.venue_pair.clone())
2029            && ex.has_ohlc()
2030        {
2031            let interval = self.state.time_period.exchange_interval();
2032            let limit = 100;
2033            match ex.fetch_ohlc(pair, interval, limit).await {
2034                Ok(candles) => {
2035                    self.state.exchange_ohlc = candles
2036                        .into_iter()
2037                        .map(|c| OhlcCandle {
2038                            timestamp: c.open_time as f64 / 1000.0,
2039                            open: c.open,
2040                            high: c.high,
2041                            low: c.low,
2042                            close: c.close,
2043                            is_bullish: c.close >= c.open,
2044                        })
2045                        .collect();
2046                }
2047                Err(e) => {
2048                    tracing::debug!("Failed to fetch OHLC: {}", e);
2049                    // Fall back to synthetic candles silently
2050                }
2051            }
2052        }
2053
2054        // Periodically fetch holder count via chain client (~every 12th refresh ≈ 60s at 5s rate)
2055        self.state.holder_fetch_counter += 1;
2056        if self.state.holder_fetch_counter.is_multiple_of(12)
2057            && let Some(ref client) = self.chain_client
2058        {
2059            match client
2060                .get_token_holder_count(&self.state.token_address)
2061                .await
2062            {
2063                Ok(count) if count > 0 => {
2064                    self.state.holder_count = Some(count);
2065                }
2066                _ => {} // Keep previous value or None
2067            }
2068        }
2069    }
2070}
2071
2072/// Handles a key event by mutating state. Standalone version for testability.
2073/// Returns true if the application should exit.
2074#[cfg(test)]
2075fn handle_key_event_on_state(key: crossterm::event::KeyEvent, state: &mut MonitorState) -> bool {
2076    // Track last input time for auto-pause
2077    state.last_input_at = Instant::now();
2078
2079    // Widget toggle mode: waiting for digit 1-5
2080    if state.widget_toggle_mode {
2081        state.widget_toggle_mode = false;
2082        if let KeyCode::Char(c @ '1'..='5') = key.code {
2083            let idx = (c as u8 - b'0') as usize;
2084            state.widgets.toggle_by_index(idx);
2085            return false;
2086        }
2087        // Any other key cancels the mode and falls through
2088    }
2089
2090    match key.code {
2091        KeyCode::Char('q') | KeyCode::Esc => {
2092            if state.export_active {
2093                state.stop_export();
2094            }
2095            return true;
2096        }
2097        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2098            if state.export_active {
2099                state.stop_export();
2100            }
2101            return true;
2102        }
2103        KeyCode::Char('r') => {
2104            state.force_refresh();
2105        }
2106        // Shift+P toggles auto-pause
2107        KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
2108            state.auto_pause_on_input = !state.auto_pause_on_input;
2109            state.log(format!(
2110                "Auto-pause: {}",
2111                if state.auto_pause_on_input {
2112                    "ON"
2113                } else {
2114                    "OFF"
2115                }
2116            ));
2117        }
2118        KeyCode::Char('p') | KeyCode::Char(' ') => {
2119            state.toggle_pause();
2120        }
2121        // Toggle CSV export
2122        KeyCode::Char('e') => {
2123            state.toggle_export();
2124        }
2125        KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
2126            state.slower_refresh();
2127        }
2128        KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
2129            state.faster_refresh();
2130        }
2131        KeyCode::Char('1') => {
2132            state.set_time_period(TimePeriod::Min1);
2133        }
2134        KeyCode::Char('2') => {
2135            state.set_time_period(TimePeriod::Min5);
2136        }
2137        KeyCode::Char('3') => {
2138            state.set_time_period(TimePeriod::Min15);
2139        }
2140        KeyCode::Char('4') => {
2141            state.set_time_period(TimePeriod::Hour1);
2142        }
2143        KeyCode::Char('5') => {
2144            state.set_time_period(TimePeriod::Hour4);
2145        }
2146        KeyCode::Char('6') => {
2147            state.set_time_period(TimePeriod::Day1);
2148        }
2149        KeyCode::Char('t') | KeyCode::Tab => {
2150            state.cycle_time_period();
2151        }
2152        KeyCode::Char('c') => {
2153            state.toggle_chart_mode();
2154        }
2155        KeyCode::Char('s') => {
2156            state.scale_mode = state.scale_mode.toggle();
2157            state.log(format!("Scale: {}", state.scale_mode.label()));
2158        }
2159        KeyCode::Char('/') => {
2160            state.color_scheme = state.color_scheme.next();
2161            state.log(format!("Colors: {}", state.color_scheme.label()));
2162        }
2163        KeyCode::Char('j') | KeyCode::Down => {
2164            state.scroll_log_down();
2165        }
2166        KeyCode::Char('k') | KeyCode::Up => {
2167            state.scroll_log_up();
2168        }
2169        KeyCode::Char('l') => {
2170            state.layout = state.layout.next();
2171            state.auto_layout = false;
2172        }
2173        KeyCode::Char('h') => {
2174            state.layout = state.layout.prev();
2175            state.auto_layout = false;
2176        }
2177        KeyCode::Char('w') => {
2178            state.widget_toggle_mode = true;
2179        }
2180        KeyCode::Char('a') => {
2181            state.auto_layout = true;
2182        }
2183        _ => {}
2184    }
2185    false
2186}
2187
2188/// Renders the UI.
2189/// Computed layout areas for each widget. `None` means the widget is hidden.
2190struct LayoutAreas {
2191    price_chart: Option<Rect>,
2192    volume_chart: Option<Rect>,
2193    buy_sell_gauge: Option<Rect>,
2194    metrics_panel: Option<Rect>,
2195    activity_feed: Option<Rect>,
2196    /// Order book depth panel (Exchange layout).
2197    order_book: Option<Rect>,
2198    /// Market info panel with pair details (Exchange layout).
2199    market_info: Option<Rect>,
2200    /// Recent trade history (Exchange layout).
2201    trade_history: Option<Rect>,
2202}
2203
2204/// Dashboard layout: charts top, gauges middle, transaction feed bottom.
2205///
2206/// ```text
2207/// ┌──────────────────────┬──────────────────────┐
2208/// │  Price Chart (60%)   │  Volume Chart (40%)  │  55%
2209/// ├──────────────────────┼──────────────────────┤
2210/// │  Buy/Sell (50%)      │  Metrics (50%)       │  20%
2211/// ├──────────────────────┴──────────────────────┤
2212/// │  Activity Feed                              │  25%
2213/// └─────────────────────────────────────────────┘
2214/// ```
2215fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2216    let rows = Layout::default()
2217        .direction(Direction::Vertical)
2218        .constraints([
2219            Constraint::Percentage(55),
2220            Constraint::Percentage(20),
2221            Constraint::Percentage(25),
2222        ])
2223        .split(area);
2224
2225    let top = Layout::default()
2226        .direction(Direction::Horizontal)
2227        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2228        .split(rows[0]);
2229
2230    let middle = Layout::default()
2231        .direction(Direction::Horizontal)
2232        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2233        .split(rows[1]);
2234
2235    LayoutAreas {
2236        price_chart: if widgets.price_chart {
2237            Some(top[0])
2238        } else {
2239            None
2240        },
2241        volume_chart: if widgets.volume_chart {
2242            Some(top[1])
2243        } else {
2244            None
2245        },
2246        buy_sell_gauge: if widgets.buy_sell_pressure {
2247            Some(middle[0])
2248        } else {
2249            None
2250        },
2251        metrics_panel: if widgets.metrics_panel {
2252            Some(middle[1])
2253        } else {
2254            None
2255        },
2256        activity_feed: if widgets.activity_log {
2257            Some(rows[2])
2258        } else {
2259            None
2260        },
2261        order_book: None,
2262        market_info: None,
2263        trade_history: None,
2264    }
2265}
2266
2267/// Chart-focus layout: full-width candles with minimal stats overlay.
2268///
2269/// ```text
2270/// ┌─────────────────────────────────────────────┐
2271/// │                                             │
2272/// │            Price Chart (~85%)                │
2273/// │                                             │
2274/// ├─────────────────────────────────────────────┤
2275/// │  Metrics (compact stats overlay)     ~15%   │
2276/// └─────────────────────────────────────────────┘
2277/// ```
2278fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2279    let vertical = Layout::default()
2280        .direction(Direction::Vertical)
2281        .constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
2282        .split(area);
2283
2284    LayoutAreas {
2285        price_chart: if widgets.price_chart {
2286            Some(vertical[0])
2287        } else {
2288            None
2289        },
2290        volume_chart: None,   // Hidden in chart-focus
2291        buy_sell_gauge: None, // Hidden in chart-focus
2292        metrics_panel: if widgets.metrics_panel {
2293            Some(vertical[1])
2294        } else {
2295            None
2296        },
2297        activity_feed: None, // Hidden in chart-focus
2298        order_book: None,
2299        market_info: None,
2300        trade_history: None,
2301    }
2302}
2303
2304/// Feed layout: transaction log takes priority, small price ticker on top.
2305///
2306/// ```text
2307/// ┌──────────────────────┬──────────────────────┐
2308/// │  Metrics (50%)       │  Buy/Sell (50%)      │  25%
2309/// ├──────────────────────┴──────────────────────┤
2310/// │                                             │
2311/// │            Activity Feed (~75%)             │
2312/// │                                             │
2313/// └─────────────────────────────────────────────┘
2314/// ```
2315fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2316    let vertical = Layout::default()
2317        .direction(Direction::Vertical)
2318        .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
2319        .split(area);
2320
2321    let top = Layout::default()
2322        .direction(Direction::Horizontal)
2323        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2324        .split(vertical[0]);
2325
2326    LayoutAreas {
2327        price_chart: None,  // Hidden in feed mode
2328        volume_chart: None, // Hidden in feed mode
2329        metrics_panel: if widgets.metrics_panel {
2330            Some(top[0])
2331        } else {
2332            None
2333        },
2334        buy_sell_gauge: if widgets.buy_sell_pressure {
2335            Some(top[1])
2336        } else {
2337            None
2338        },
2339        activity_feed: if widgets.activity_log {
2340            Some(vertical[1])
2341        } else {
2342            None
2343        },
2344        order_book: None,
2345        market_info: None,
2346        trade_history: None,
2347    }
2348}
2349
2350/// Compact layout: price sparkline and metrics only for small terminals.
2351///
2352/// ```text
2353/// ┌─────────────────────────────────────────────┐
2354/// │  Metrics Panel (sparkline + stats)    100%  │
2355/// └─────────────────────────────────────────────┘
2356/// ```
2357fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2358    LayoutAreas {
2359        price_chart: None,    // Hidden in compact
2360        volume_chart: None,   // Hidden in compact
2361        buy_sell_gauge: None, // Hidden in compact
2362        metrics_panel: if widgets.metrics_panel {
2363            Some(area)
2364        } else {
2365            None
2366        },
2367        activity_feed: None, // Hidden in compact
2368        order_book: None,
2369        market_info: None,
2370        trade_history: None,
2371    }
2372}
2373
2374/// Exchange layout: order book left, chart center, trade history right.
2375///
2376/// ```text
2377/// ┌─────────────────┬──────────────────────────┬─────────────────┐
2378/// │                 │                          │                 │
2379/// │  Order Book     │   Price Chart (45%)      │  Trade History  │  60%
2380/// │    (25%)        │                          │    (30%)        │
2381/// │                 │                          │                 │
2382/// ├─────────────────┼──────────────────────────┼─────────────────┤
2383/// │  Buy/Sell (25%) │   Market Info (45%)      │  (continued)    │  40%
2384/// │                 │                          │    (30%)        │
2385/// └─────────────────┴──────────────────────────┴─────────────────┘
2386/// ```
2387fn layout_exchange(area: Rect, _widgets: &WidgetVisibility) -> LayoutAreas {
2388    let rows = Layout::default()
2389        .direction(Direction::Vertical)
2390        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2391        .split(area);
2392
2393    let top = Layout::default()
2394        .direction(Direction::Horizontal)
2395        .constraints([
2396            Constraint::Percentage(25),
2397            Constraint::Percentage(45),
2398            Constraint::Percentage(30),
2399        ])
2400        .split(rows[0]);
2401
2402    let bottom = Layout::default()
2403        .direction(Direction::Horizontal)
2404        .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
2405        .split(rows[1]);
2406
2407    LayoutAreas {
2408        price_chart: Some(top[1]),
2409        volume_chart: None,
2410        buy_sell_gauge: Some(bottom[0]),
2411        metrics_panel: None,
2412        activity_feed: None,
2413        order_book: Some(top[0]),
2414        market_info: Some(bottom[1]),
2415        trade_history: Some(top[2]),
2416    }
2417}
2418
2419/// Selects the best layout preset based on terminal size.
2420fn auto_select_layout(size: Rect) -> LayoutPreset {
2421    match (size.width, size.height) {
2422        (w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
2423        (w, _) if w < 120 => LayoutPreset::Feed,
2424        (_, h) if h < 30 => LayoutPreset::ChartFocus,
2425        _ => LayoutPreset::Dashboard,
2426    }
2427}
2428
2429/// Renders the UI, dispatching to the active layout preset.
2430fn ui(f: &mut Frame, state: &mut MonitorState) {
2431    // Responsive breakpoint: auto-select layout for terminal size
2432    if state.auto_layout {
2433        let suggested = auto_select_layout(f.area());
2434        if suggested != state.layout {
2435            state.layout = suggested;
2436        }
2437    }
2438
2439    // Main layout: header, content, footer
2440    let chunks = Layout::default()
2441        .direction(Direction::Vertical)
2442        .constraints([
2443            Constraint::Length(4), // Header (token info + time period tabs)
2444            Constraint::Min(10),   // Content
2445            Constraint::Length(3), // Footer
2446        ])
2447        .split(f.area());
2448
2449    // Render header
2450    render_header(f, chunks[0], state);
2451
2452    // Calculate content areas based on active layout preset
2453    let areas = match state.layout {
2454        LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
2455        LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
2456        LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
2457        LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
2458        LayoutPreset::Exchange => layout_exchange(chunks[1], &state.widgets),
2459    };
2460
2461    // Render each widget if its area is allocated
2462    if let Some(area) = areas.price_chart {
2463        match state.chart_mode {
2464            ChartMode::Line => render_price_chart(f, area, state),
2465            ChartMode::Candlestick => render_candlestick_chart(f, area, state),
2466            ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
2467        }
2468    }
2469    if let Some(area) = areas.volume_chart {
2470        render_volume_chart(f, area, &*state);
2471    }
2472    if let Some(area) = areas.buy_sell_gauge {
2473        render_buy_sell_gauge(f, area, state);
2474    }
2475    if let Some(area) = areas.metrics_panel {
2476        // Split metrics area to show liquidity depth if enabled and data available
2477        if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
2478            let split = Layout::default()
2479                .direction(Direction::Vertical)
2480                .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2481                .split(area);
2482            render_metrics_panel(f, split[0], &*state);
2483            render_liquidity_depth(f, split[1], &*state);
2484        } else {
2485            render_metrics_panel(f, area, &*state);
2486        }
2487    }
2488    if let Some(area) = areas.activity_feed {
2489        render_activity_feed(f, area, state);
2490    }
2491    // Exchange-specific widgets
2492    if let Some(area) = areas.order_book {
2493        render_order_book_panel(f, area, state);
2494    }
2495    if let Some(area) = areas.market_info {
2496        render_market_info_panel(f, area, state);
2497    }
2498    if let Some(area) = areas.trade_history {
2499        render_recent_trades_panel(f, area, state);
2500    }
2501
2502    // Render alert overlay on top of content area if alerts are active
2503    if !state.active_alerts.is_empty() {
2504        // Show alerts as a banner at the top of the content area (3 lines max)
2505        let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
2506        let alert_area = Rect::new(
2507            chunks[1].x,
2508            chunks[1].y,
2509            chunks[1].width,
2510            alert_height.min(chunks[1].height),
2511        );
2512        render_alert_overlay(f, alert_area, state);
2513    }
2514
2515    // Render footer
2516    render_footer(f, chunks[2], state);
2517}
2518
2519/// Renders the header with token info and time period tabs.
2520fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
2521    let price_color = if state.price_change_24h >= 0.0 {
2522        Color::Green
2523    } else {
2524        Color::Red
2525    };
2526
2527    // Use Unicode arrows for trend indication
2528    let trend_arrow = if state.price_change_24h > 0.5 {
2529        "▲"
2530    } else if state.price_change_24h < -0.5 {
2531        "▼"
2532    } else if state.price_change_24h >= 0.0 {
2533        "△"
2534    } else {
2535        "▽"
2536    };
2537
2538    let change_str = format!(
2539        "{}{:.2}%",
2540        if state.price_change_24h >= 0.0 {
2541            "+"
2542        } else {
2543            ""
2544        },
2545        state.price_change_24h
2546    );
2547
2548    let title = format!(
2549        " ◈ {} ({}) │ {} ",
2550        state.symbol,
2551        state.name,
2552        state.chain.to_uppercase(),
2553    );
2554
2555    let price_str = format_price_usd(state.current_price);
2556
2557    // Split header area: top row for token info, bottom row for tabs
2558    let header_chunks = Layout::default()
2559        .direction(Direction::Vertical)
2560        .constraints([Constraint::Length(3), Constraint::Length(1)])
2561        .split(area);
2562
2563    // Token info with price
2564    let header = Paragraph::new(Line::from(vec![
2565        Span::styled(price_str, Style::new().fg(price_color).bold()),
2566        Span::raw(" "),
2567        Span::styled(trend_arrow, Style::new().fg(price_color)),
2568        Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
2569    ]))
2570    .block(
2571        Block::default()
2572            .title(title)
2573            .borders(Borders::ALL)
2574            .border_style(Style::new().cyan()),
2575    );
2576
2577    f.render_widget(header, header_chunks[0]);
2578
2579    // Time period tabs
2580    let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
2581    let chart_label = state.chart_mode.label();
2582    let tabs = Tabs::new(tab_titles)
2583        .select(state.time_period.index())
2584        .highlight_style(Style::new().cyan().bold())
2585        .divider("│")
2586        .padding(" ", " ");
2587    let tabs_line = Layout::default()
2588        .direction(Direction::Horizontal)
2589        .constraints([Constraint::Min(20), Constraint::Length(10)])
2590        .split(header_chunks[1]);
2591    f.render_widget(tabs, tabs_line[0]);
2592    f.render_widget(
2593        Paragraph::new(Span::styled(
2594            format!("⊞ {}", chart_label),
2595            Style::new().magenta(),
2596        )),
2597        tabs_line[1],
2598    );
2599}
2600
2601/// Renders the price chart with visual differentiation between real and synthetic data.
2602fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2603    // Get data filtered by selected time period
2604    let (data, is_real) = state.get_price_data_for_period();
2605
2606    if data.is_empty() {
2607        let empty = Paragraph::new("No price data").block(
2608            Block::default()
2609                .title(" Price (USD) ")
2610                .borders(Borders::ALL),
2611        );
2612        f.render_widget(empty, area);
2613        return;
2614    }
2615
2616    // Calculate price statistics
2617    let current_price = state.current_price;
2618    let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
2619    let price_change = current_price - first_price;
2620    let price_change_pct = if first_price > 0.0 {
2621        (price_change / first_price) * 100.0
2622    } else {
2623        0.0
2624    };
2625
2626    // Determine if price is up or down for coloring
2627    let pal = state.palette();
2628    let is_price_up = price_change >= 0.0;
2629    let trend_color = if is_price_up { pal.up } else { pal.down };
2630    let trend_symbol = if is_price_up { "▲" } else { "▼" };
2631
2632    // Format current price based on magnitude
2633    let price_str = format_price_usd(current_price);
2634    let change_str = if price_change_pct.abs() < 0.01 {
2635        "0.00%".to_string()
2636    } else {
2637        format!(
2638            "{}{:.2}%",
2639            if is_price_up { "+" } else { "" },
2640            price_change_pct
2641        )
2642    };
2643
2644    // Build title with current price and change
2645    let chart_title = Line::from(vec![
2646        Span::raw(" ◆ "),
2647        Span::styled(
2648            format!("{} {} ", price_str, trend_symbol),
2649            Style::new().fg(trend_color).bold(),
2650        ),
2651        Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2652        Span::styled(
2653            format!("│{}│ ", state.time_period.label()),
2654            Style::new().gray(),
2655        ),
2656    ]);
2657
2658    // Calculate bounds
2659    let (min_price, max_price) = data
2660        .iter()
2661        .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
2662            (min.min(*p), max.max(*p))
2663        });
2664
2665    // Handle case where all prices are the same (e.g., stablecoins)
2666    let price_range = max_price - min_price;
2667    let (y_min, y_max) = if price_range < 0.0001 {
2668        // Add ±0.1% padding when prices are flat
2669        let padding = min_price * 0.001;
2670        (min_price - padding, max_price + padding)
2671    } else {
2672        (min_price - price_range * 0.1, max_price + price_range * 0.1)
2673    };
2674
2675    let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
2676    let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
2677    // Ensure x range is non-zero for proper rendering
2678    let x_max = if (x_max - x_min).abs() < 0.001 {
2679        x_min + 1.0
2680    } else {
2681        x_max
2682    };
2683
2684    // Apply scale transformation (log or linear)
2685    let apply_scale = |price: f64| -> f64 {
2686        match state.scale_mode {
2687            ScaleMode::Linear => price,
2688            ScaleMode::Log => {
2689                if price > 0.0 {
2690                    price.ln()
2691                } else {
2692                    0.0
2693                }
2694            }
2695        }
2696    };
2697
2698    let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
2699
2700    // Split data into synthetic and real datasets for visual differentiation
2701    let synthetic_data: Vec<(f64, f64)> = data
2702        .iter()
2703        .zip(&is_real)
2704        .filter(|(_, real)| !**real)
2705        .map(|((t, p), _)| (*t, apply_scale(*p)))
2706        .collect();
2707
2708    let real_data: Vec<(f64, f64)> = data
2709        .iter()
2710        .zip(&is_real)
2711        .filter(|(_, real)| **real)
2712        .map(|((t, p), _)| (*t, apply_scale(*p)))
2713        .collect();
2714
2715    // Create reference line at first price (horizontal line for comparison)
2716    let reference_line: Vec<(f64, f64)> = vec![
2717        (x_min, apply_scale(first_price)),
2718        (x_max, apply_scale(first_price)),
2719    ];
2720
2721    let mut datasets = Vec::new();
2722
2723    // Reference line (starting price) - dashed gray
2724    datasets.push(
2725        Dataset::default()
2726            .name("━Start")
2727            .marker(symbols::Marker::Braille)
2728            .graph_type(GraphType::Line)
2729            .style(Style::new().dark_gray())
2730            .data(&reference_line),
2731    );
2732
2733    // Synthetic data shown with Dot marker and dimmed color
2734    if !synthetic_data.is_empty() {
2735        datasets.push(
2736            Dataset::default()
2737                .name("◇Est")
2738                .marker(symbols::Marker::Braille)
2739                .graph_type(GraphType::Line)
2740                .style(Style::new().cyan())
2741                .data(&synthetic_data),
2742        );
2743    }
2744
2745    // Real data shown with Braille marker and trend color
2746    if !real_data.is_empty() {
2747        datasets.push(
2748            Dataset::default()
2749                .name("●Live")
2750                .marker(symbols::Marker::Braille)
2751                .graph_type(GraphType::Line)
2752                .style(Style::new().fg(trend_color))
2753                .data(&real_data),
2754        );
2755    }
2756
2757    // Create time labels based on period
2758    let time_label = format!("-{}", state.time_period.label());
2759
2760    // Calculate middle price for 3-point y-axis labels
2761    // In log mode, labels show original USD prices (exp of log values)
2762    let mid_y = (y_min + y_max) / 2.0;
2763    let y_label = |val: f64| -> String {
2764        match state.scale_mode {
2765            ScaleMode::Linear => format_price_usd(val),
2766            ScaleMode::Log => format_price_usd(val.exp()),
2767        }
2768    };
2769
2770    let scale_label = match state.scale_mode {
2771        ScaleMode::Linear => "USD",
2772        ScaleMode::Log => "USD (log)",
2773    };
2774
2775    let chart = Chart::new(datasets)
2776        .block(
2777            Block::default()
2778                .title(chart_title)
2779                .borders(Borders::ALL)
2780                .border_style(Style::new().fg(trend_color)),
2781        )
2782        .x_axis(
2783            Axis::default()
2784                .title(Span::styled("Time", Style::new().gray()))
2785                .style(Style::new().gray())
2786                .bounds([x_min, x_max])
2787                .labels(vec![Span::raw(time_label), Span::raw("now")]),
2788        )
2789        .y_axis(
2790            Axis::default()
2791                .title(Span::styled(scale_label, Style::new().gray()))
2792                .style(Style::new().gray())
2793                .bounds([y_min, y_max])
2794                .labels(vec![
2795                    Span::raw(y_label(y_min)),
2796                    Span::raw(y_label(mid_y)),
2797                    Span::raw(y_label(y_max)),
2798                ]),
2799        );
2800
2801    f.render_widget(chart, area);
2802}
2803
2804/// Checks if a price indicates a stablecoin (pegged around $1.00).
2805fn is_stablecoin_price(price: f64) -> bool {
2806    (0.95..=1.05).contains(&price)
2807}
2808
2809/// Formats a price in USD with appropriate precision.
2810/// Stablecoins get extra precision (6 decimals) to show micro-fluctuations.
2811fn format_price_usd(price: f64) -> String {
2812    if price >= 1000.0 {
2813        format!("${:.2}", price)
2814    } else if is_stablecoin_price(price) {
2815        // Stablecoins get 6 decimals to show micro-fluctuations
2816        format!("${:.6}", price)
2817    } else if price >= 1.0 {
2818        format!("${:.4}", price)
2819    } else if price >= 0.01 {
2820        format!("${:.6}", price)
2821    } else if price >= 0.0001 {
2822        format!("${:.8}", price)
2823    } else {
2824        format!("${:.10}", price)
2825    }
2826}
2827
2828/// Renders a candlestick chart using OHLC data.
2829fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2830    let candles = state.get_ohlc_candles();
2831
2832    if candles.is_empty() {
2833        let empty = Paragraph::new("No candle data (waiting for more data points)").block(
2834            Block::default()
2835                .title(" Candlestick (USD) ")
2836                .borders(Borders::ALL),
2837        );
2838        f.render_widget(empty, area);
2839        return;
2840    }
2841
2842    // Calculate price statistics
2843    let current_price = state.current_price;
2844    let first_candle = candles.first().unwrap();
2845    let last_candle = candles.last().unwrap();
2846    let price_change = last_candle.close - first_candle.open;
2847    let price_change_pct = if first_candle.open > 0.0 {
2848        (price_change / first_candle.open) * 100.0
2849    } else {
2850        0.0
2851    };
2852
2853    let pal = state.palette();
2854    let is_price_up = price_change >= 0.0;
2855    let trend_color = if is_price_up { pal.up } else { pal.down };
2856    let trend_symbol = if is_price_up { "▲" } else { "▼" };
2857
2858    let price_str = format_price_usd(current_price);
2859    let change_str = format!(
2860        "{}{:.2}%",
2861        if is_price_up { "+" } else { "" },
2862        price_change_pct
2863    );
2864
2865    // Calculate bounds from all candle high/low
2866    let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
2867        (min.min(c.low), max.max(c.high))
2868    });
2869
2870    let price_range = max_price - min_price;
2871    let (y_min, y_max) = if price_range < 0.0001 {
2872        let padding = min_price * 0.001;
2873        (min_price - padding, max_price + padding)
2874    } else {
2875        (min_price - price_range * 0.1, max_price + price_range * 0.1)
2876    };
2877
2878    let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
2879    let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
2880    let x_range = x_max - x_min;
2881    let x_max = if x_range < 0.001 {
2882        x_min + 1.0
2883    } else {
2884        x_max + x_range * 0.05
2885    };
2886
2887    // Calculate candle width based on number of candles and area
2888    let candle_count = candles.len() as f64;
2889    let candle_spacing = x_range / candle_count.max(1.0);
2890    let candle_width = candle_spacing * 0.6; // 60% of spacing for body
2891
2892    let title = Line::from(vec![
2893        Span::raw(" ⬡ "),
2894        Span::styled(
2895            format!("{} {} ", price_str, trend_symbol),
2896            Style::new().fg(trend_color).bold(),
2897        ),
2898        Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2899        Span::styled(
2900            format!("│{}│ ", state.time_period.label()),
2901            Style::new().gray(),
2902        ),
2903        Span::styled("⊞Candles ", Style::new().magenta()),
2904    ]);
2905
2906    // Apply scale transformation (log or linear)
2907    let apply_scale = |price: f64| -> f64 {
2908        match state.scale_mode {
2909            ScaleMode::Linear => price,
2910            ScaleMode::Log => {
2911                if price > 0.0 {
2912                    price.ln()
2913                } else {
2914                    0.0
2915                }
2916            }
2917        }
2918    };
2919    let scaled_y_min = apply_scale(y_min);
2920    let scaled_y_max = apply_scale(y_max);
2921    let scaled_price_range = scaled_y_max - scaled_y_min;
2922
2923    // Clone candles for the closure
2924    let candles_clone = candles.clone();
2925    let is_log = matches!(state.scale_mode, ScaleMode::Log);
2926    let pal_up = pal.up;
2927    let pal_down = pal.down;
2928
2929    let canvas = Canvas::default()
2930        .block(
2931            Block::default()
2932                .title(title)
2933                .borders(Borders::ALL)
2934                .border_style(Style::new().fg(trend_color)),
2935        )
2936        .x_bounds([x_min - candle_spacing, x_max])
2937        .y_bounds([scaled_y_min, scaled_y_max])
2938        .paint(move |ctx| {
2939            let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
2940            for candle in &candles_clone {
2941                let color = if candle.is_bullish { pal_up } else { pal_down };
2942
2943                // Draw the wick (high-low line)
2944                ctx.draw(&CanvasLine {
2945                    x1: candle.timestamp,
2946                    y1: scale_fn(candle.low),
2947                    x2: candle.timestamp,
2948                    y2: scale_fn(candle.high),
2949                    color,
2950                });
2951
2952                // Draw the body (open-close rectangle)
2953                let body_top = scale_fn(candle.open.max(candle.close));
2954                let body_bottom = scale_fn(candle.open.min(candle.close));
2955                let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
2956
2957                ctx.draw(&Rectangle {
2958                    x: candle.timestamp - candle_width / 2.0,
2959                    y: body_bottom,
2960                    width: candle_width,
2961                    height: body_height,
2962                    color,
2963                });
2964            }
2965        });
2966
2967    f.render_widget(canvas, area);
2968}
2969
2970/// Renders a volume profile chart showing volume distribution by price level.
2971///
2972/// Buckets the price+volume history into horizontal bars where each bar shows
2973/// the accumulated volume at that price level. Accuracy improves over longer
2974/// monitoring sessions as more data points are collected.
2975fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2976    let pal = state.palette();
2977    let (price_data, _) = state.get_price_data_for_period();
2978    let (volume_data, _) = state.get_volume_data_for_period();
2979
2980    if price_data.len() < 2 || volume_data.is_empty() {
2981        let block = Block::default()
2982            .title(" ◨ Volume Profile (collecting data...) ")
2983            .borders(Borders::ALL)
2984            .border_style(Style::new().fg(Color::DarkGray));
2985        f.render_widget(block, area);
2986        return;
2987    }
2988
2989    // Find price range
2990    let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
2991    let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
2992
2993    if (max_price - min_price).abs() < f64::EPSILON {
2994        let block = Block::default()
2995            .title(" ◨ Volume Profile (no price range) ")
2996            .borders(Borders::ALL)
2997            .border_style(Style::new().fg(Color::DarkGray));
2998        f.render_widget(block, area);
2999        return;
3000    }
3001
3002    // Number of price buckets = available height minus borders
3003    let inner_height = area.height.saturating_sub(2) as usize;
3004    let num_buckets = inner_height.clamp(1, 30);
3005    let bucket_size = (max_price - min_price) / num_buckets as f64;
3006
3007    // Accumulate volume per price bucket
3008    let mut bucket_volumes = vec![0.0_f64; num_buckets];
3009
3010    // Pair price and volume data by index (they have same timestamps)
3011    let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
3012    for (i, (_, price)) in price_data.iter().enumerate() {
3013        let bucket_idx =
3014            (((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
3015        // Use volume delta if available, otherwise use a unit contribution
3016        let vol_contribution = if i < vol_iter.len() {
3017            // Use relative volume (delta from previous if possible)
3018            if i > 0 {
3019                (vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
3020            } else {
3021                1.0
3022            }
3023        } else {
3024            1.0
3025        };
3026        bucket_volumes[bucket_idx] += vol_contribution;
3027    }
3028
3029    let max_vol = bucket_volumes
3030        .iter()
3031        .cloned()
3032        .fold(0.0_f64, f64::max)
3033        .max(1.0);
3034
3035    // Find the bucket containing the current price
3036    let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
3037        .min(num_buckets.saturating_sub(1));
3038
3039    // Build horizontal bars using Paragraph with spans
3040    let inner_width = area.width.saturating_sub(12) as usize; // leave room for price labels
3041
3042    let lines: Vec<Line> = (0..num_buckets)
3043        .rev() // top = highest price
3044        .map(|i| {
3045            let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
3046            let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
3047            let label = if price_mid >= 1.0 {
3048                format!("{:>8.2}", price_mid)
3049            } else {
3050                format!("{:>8.6}", price_mid)
3051            };
3052            let bar_str = "█".repeat(bar_width);
3053            let style = if i == current_bucket {
3054                Style::new().fg(pal.highlight).bold()
3055            } else {
3056                Style::new().fg(pal.sparkline)
3057            };
3058            Line::from(vec![
3059                Span::styled(label, Style::new().dark_gray()),
3060                Span::raw(" "),
3061                Span::styled(bar_str, style),
3062            ])
3063        })
3064        .collect();
3065
3066    let block = Block::default()
3067        .title(" ◨ Volume Profile (accuracy improves over time) ")
3068        .borders(Borders::ALL)
3069        .border_style(Style::new().fg(pal.sparkline));
3070
3071    let paragraph = Paragraph::new(lines).block(block);
3072    f.render_widget(paragraph, area);
3073}
3074
3075/// Renders the volume chart with visual differentiation between real and synthetic data.
3076fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
3077    let pal = state.palette();
3078    // Get data filtered by selected time period
3079    let (data, is_real) = state.get_volume_data_for_period();
3080
3081    if data.is_empty() {
3082        let empty = Paragraph::new("No volume data")
3083            .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
3084        f.render_widget(empty, area);
3085        return;
3086    }
3087
3088    // Get current volume for display
3089    let current_volume = state.volume_24h;
3090    let volume_str = crate::display::format_usd(current_volume);
3091
3092    // Count synthetic vs real points for the legend
3093    let has_synthetic = is_real.iter().any(|r| !r);
3094    let has_real = is_real.iter().any(|r| *r);
3095
3096    // Build title with current volume
3097    let data_indicator = if has_synthetic && has_real {
3098        "[◆ est │ ● live]"
3099    } else if has_synthetic {
3100        "[◆ estimated]"
3101    } else {
3102        "[● live]"
3103    };
3104
3105    let chart_title = Line::from(vec![
3106        Span::raw(" ▣ "),
3107        Span::styled(
3108            format!("24h Vol: {} ", volume_str),
3109            Style::new().fg(pal.volume_bar).bold(),
3110        ),
3111        Span::styled(
3112            format!("│{}│ ", state.time_period.label()),
3113            Style::new().gray(),
3114        ),
3115        Span::styled(data_indicator, Style::new().dark_gray()),
3116    ]);
3117
3118    // Build bars from data points — bucket into a reasonable number of bars
3119    // based on available width (each bar needs at least 3 chars)
3120    let inner_width = area.width.saturating_sub(2) as usize; // account for block borders
3121    let max_bars = (inner_width / 3).max(1).min(data.len());
3122    let bucket_size = data.len().div_ceil(max_bars);
3123
3124    let bars: Vec<Bar> = data
3125        .chunks(bucket_size)
3126        .zip(is_real.chunks(bucket_size))
3127        .enumerate()
3128        .map(|(i, (chunk, real_chunk))| {
3129            let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
3130            let any_real = real_chunk.iter().any(|r| *r);
3131            let bar_color = if any_real {
3132                pal.volume_bar
3133            } else {
3134                pal.neutral
3135            };
3136            // Show time labels at start, middle, and end
3137            let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
3138                format_number(avg_vol)
3139            } else {
3140                String::new()
3141            };
3142            Bar::default()
3143                .value(avg_vol as u64)
3144                .label(Line::from(label))
3145                .style(Style::new().fg(bar_color))
3146        })
3147        .collect();
3148
3149    // Calculate dynamic bar width based on available space
3150    let bar_width = if !bars.is_empty() {
3151        let total_bars = bars.len() as u16;
3152        // Each bar gets: bar_width + 1 gap, minus 1 gap for the last bar
3153        ((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
3154    } else {
3155        1
3156    }
3157    .max(1);
3158
3159    let barchart = BarChart::default()
3160        .data(BarGroup::default().bars(&bars))
3161        .block(
3162            Block::default()
3163                .title(chart_title)
3164                .borders(Borders::ALL)
3165                .border_style(Style::new().blue()),
3166        )
3167        .bar_width(bar_width)
3168        .bar_gap(1)
3169        .value_style(Style::new().dark_gray());
3170
3171    f.render_widget(barchart, area);
3172}
3173
3174/// Renders the buy/sell ratio gauge (pressure bar only, no activity log).
3175fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
3176    let pal = state.palette();
3177    // Buy/Sell ratio bar
3178    let ratio = state.buy_ratio();
3179    let border_color = if ratio > 0.5 { pal.up } else { pal.down };
3180
3181    let block = Block::default()
3182        .title(" ◐ Buy/Sell Ratio (24h) ")
3183        .borders(Borders::ALL)
3184        .border_style(Style::new().fg(border_color));
3185
3186    let inner = block.inner(area);
3187    f.render_widget(block, area);
3188
3189    if inner.width > 0 && inner.height > 0 {
3190        let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
3191        let sell_width = inner.width.saturating_sub(buy_width);
3192
3193        let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
3194        let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
3195        let label = format!(
3196            "{}Buys: {} │ Sells: {}{} ({:.1}%)",
3197            buy_indicator,
3198            state.buys_24h,
3199            state.sells_24h,
3200            sell_indicator,
3201            ratio * 100.0
3202        );
3203
3204        let buy_bar = "█".repeat(buy_width as usize);
3205        let sell_bar = "█".repeat(sell_width as usize);
3206        let bar_line = Line::from(vec![
3207            Span::styled(buy_bar, Style::new().fg(pal.up)),
3208            Span::styled(sell_bar, Style::new().fg(pal.down)),
3209        ]);
3210        f.render_widget(Paragraph::new(bar_line), inner);
3211
3212        // Center the label on top of the bar
3213        let label_len = label.len() as u16;
3214        if label_len <= inner.width {
3215            let x_offset = (inner.width.saturating_sub(label_len)) / 2;
3216            let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
3217            let label_widget =
3218                Paragraph::new(Span::styled(label, Style::new().fg(Color::White).bold()));
3219            f.render_widget(label_widget, label_area);
3220        }
3221    }
3222}
3223
3224/// Renders the scrollable activity log feed.
3225fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
3226    let log_len = state.log_messages.len();
3227    let log_title = if log_len > 0 {
3228        let selected = state.log_list_state.selected().unwrap_or(0);
3229        format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
3230    } else {
3231        " ◷ Activity Log ".to_string()
3232    };
3233
3234    let items: Vec<ListItem> = state
3235        .log_messages
3236        .iter()
3237        .rev()
3238        .map(|msg| ListItem::new(msg.as_str()).style(Style::new().gray()))
3239        .collect();
3240
3241    let log_list = List::new(items)
3242        .block(
3243            Block::default()
3244                .title(log_title)
3245                .borders(Borders::ALL)
3246                .border_style(Style::new().dark_gray()),
3247        )
3248        .highlight_style(Style::new().white().bold())
3249        .highlight_symbol("▸ ");
3250
3251    f.render_stateful_widget(log_list, area, &mut state.log_list_state);
3252}
3253
3254/// Renders a flashing alert overlay when alerts are active.
3255fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
3256    if state.active_alerts.is_empty() {
3257        return;
3258    }
3259
3260    let is_flash_on = state
3261        .alert_flash_until
3262        .map(|deadline| {
3263            if Instant::now() < deadline {
3264                // Flash with ~500ms period
3265                (Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
3266            } else {
3267                false
3268            }
3269        })
3270        .unwrap_or(false);
3271
3272    let border_color = if is_flash_on {
3273        Color::Red
3274    } else {
3275        Color::Yellow
3276    };
3277
3278    let alert_lines: Vec<Line> = state
3279        .active_alerts
3280        .iter()
3281        .map(|a| Line::from(Span::styled(&a.message, Style::new().fg(Color::Red).bold())))
3282        .collect();
3283
3284    let alert_widget = Paragraph::new(alert_lines).block(
3285        Block::default()
3286            .title(" ⚠ ALERTS ")
3287            .borders(Borders::ALL)
3288            .border_style(Style::new().fg(border_color).bold()),
3289    );
3290
3291    f.render_widget(alert_widget, area);
3292}
3293
3294/// Renders a horizontal stacked bar chart of per-pair liquidity.
3295fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
3296    let pal = state.palette();
3297
3298    if state.liquidity_pairs.is_empty() {
3299        let block = Block::default()
3300            .title(" ◫ Liquidity Depth (no data) ")
3301            .borders(Borders::ALL)
3302            .border_style(Style::new().fg(Color::DarkGray));
3303        f.render_widget(block, area);
3304        return;
3305    }
3306
3307    let max_liquidity = state
3308        .liquidity_pairs
3309        .iter()
3310        .map(|(_, liq)| *liq)
3311        .fold(0.0_f64, f64::max)
3312        .max(1.0);
3313
3314    let inner_width = area.width.saturating_sub(2) as usize;
3315
3316    let lines: Vec<Line> = state
3317        .liquidity_pairs
3318        .iter()
3319        .take(area.height.saturating_sub(2) as usize) // limit to available rows
3320        .map(|(name, liq)| {
3321            let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
3322            let bar_str = "█".repeat(bar_width);
3323            let label = format!(" {} {}", crate::display::format_usd(*liq), name);
3324            Line::from(vec![
3325                Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
3326                Span::styled(label, Style::new().fg(pal.neutral)),
3327            ])
3328        })
3329        .collect();
3330
3331    let block = Block::default()
3332        .title(format!(
3333            " ◫ Liquidity Depth ({} pairs) ",
3334            state.liquidity_pairs.len()
3335        ))
3336        .borders(Borders::ALL)
3337        .border_style(Style::new().fg(pal.border));
3338
3339    let paragraph = Paragraph::new(lines).block(block);
3340    f.render_widget(paragraph, area);
3341}
3342
3343/// Renders the key metrics panel.
3344fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3345    let pal = state.palette();
3346    // Split panel: top sparkline (2 rows), bottom table
3347    let chunks = Layout::default()
3348        .direction(Direction::Vertical)
3349        .constraints([Constraint::Length(3), Constraint::Min(0)])
3350        .split(area);
3351
3352    // --- Sparkline: recent price trend ---
3353    let sparkline_data: Vec<u64> = {
3354        // Take the last N price points and normalize to u64 for Sparkline
3355        let points: Vec<f64> = state.price_history.iter().map(|dp| dp.value).collect();
3356        if points.len() < 2 {
3357            vec![0; chunks[0].width.saturating_sub(2) as usize]
3358        } else {
3359            let min_p = points.iter().cloned().fold(f64::MAX, f64::min);
3360            let max_p = points.iter().cloned().fold(f64::MIN, f64::max);
3361            let range = (max_p - min_p).max(0.0001);
3362            points
3363                .iter()
3364                .map(|p| (((*p - min_p) / range) * 100.0) as u64)
3365                .collect()
3366        }
3367    };
3368
3369    let trend_color = if state.price_change_5m >= 0.0 {
3370        pal.up
3371    } else {
3372        pal.down
3373    };
3374
3375    let sparkline = Sparkline::default()
3376        .block(
3377            Block::default()
3378                .title(" ◉ Price Trend ")
3379                .borders(Borders::ALL)
3380                .border_style(Style::new().fg(pal.sparkline)),
3381        )
3382        .data(&sparkline_data)
3383        .style(Style::new().fg(trend_color));
3384
3385    f.render_widget(sparkline, chunks[0]);
3386
3387    // --- Table: key metrics ---
3388    let change_5m_str = if state.price_change_5m.abs() < 0.0001 {
3389        "0.00%".to_string()
3390    } else {
3391        format!("{:+.4}%", state.price_change_5m)
3392    };
3393    let change_5m_color = if state.price_change_5m > 0.0 {
3394        pal.up
3395    } else if state.price_change_5m < 0.0 {
3396        pal.down
3397    } else {
3398        pal.neutral
3399    };
3400
3401    let now_ts = chrono::Utc::now().timestamp() as f64;
3402    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3403    let last_change_str = if secs_since_change < 60 {
3404        format!("{}s ago", secs_since_change)
3405    } else if secs_since_change < 3600 {
3406        format!("{}m ago", secs_since_change / 60)
3407    } else {
3408        format!("{}h ago", secs_since_change / 3600)
3409    };
3410    let last_change_color = if secs_since_change < 60 {
3411        pal.up
3412    } else {
3413        pal.highlight
3414    };
3415
3416    let change_24h_str = format!(
3417        "{}{:.2}%",
3418        if state.price_change_24h >= 0.0 {
3419            "+"
3420        } else {
3421            ""
3422        },
3423        state.price_change_24h
3424    );
3425
3426    let market_cap_str = state
3427        .market_cap
3428        .map(crate::display::format_usd)
3429        .unwrap_or_else(|| "N/A".to_string());
3430
3431    let mut rows = vec![
3432        Row::new(vec![
3433            Span::styled("Price", Style::new().gray()),
3434            Span::styled(format_price_usd(state.current_price), Style::new().bold()),
3435        ]),
3436        Row::new(vec![
3437            Span::styled("5m Chg", Style::new().gray()),
3438            Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
3439        ]),
3440        Row::new(vec![
3441            Span::styled("Last Δ", Style::new().gray()),
3442            Span::styled(last_change_str, Style::new().fg(last_change_color)),
3443        ]),
3444        Row::new(vec![
3445            Span::styled("24h Chg", Style::new().gray()),
3446            Span::raw(change_24h_str),
3447        ]),
3448        Row::new(vec![
3449            Span::styled("Liq", Style::new().gray()),
3450            Span::raw(crate::display::format_usd(state.liquidity_usd)),
3451        ]),
3452        Row::new(vec![
3453            Span::styled("Vol 24h", Style::new().gray()),
3454            Span::raw(crate::display::format_usd(state.volume_24h)),
3455        ]),
3456        Row::new(vec![
3457            Span::styled("Mkt Cap", Style::new().gray()),
3458            Span::raw(market_cap_str),
3459        ]),
3460        Row::new(vec![
3461            Span::styled("Buys", Style::new().gray()),
3462            Span::styled(format!("{}", state.buys_24h), Style::new().fg(pal.up)),
3463        ]),
3464        Row::new(vec![
3465            Span::styled("Sells", Style::new().gray()),
3466            Span::styled(format!("{}", state.sells_24h), Style::new().fg(pal.down)),
3467        ]),
3468    ];
3469
3470    // Add holder count if available and the widget is enabled
3471    if state.widgets.holder_count
3472        && let Some(count) = state.holder_count
3473    {
3474        rows.push(Row::new(vec![
3475            Span::styled("Holders", Style::new().gray()),
3476            Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
3477        ]));
3478    }
3479
3480    let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
3481        Block::default()
3482            .title(" ◉ Key Metrics ")
3483            .borders(Borders::ALL)
3484            .border_style(Style::new().magenta()),
3485    );
3486
3487    f.render_widget(table, chunks[1]);
3488}
3489
3490/// Renders the order book panel for the Exchange layout.
3491///
3492/// Shows asks (descending), spread, bids (descending) with depth bars.
3493fn render_order_book_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3494    let pal = state.palette();
3495
3496    let book = match &state.order_book {
3497        Some(b) => b,
3498        None => {
3499            let block = Block::default()
3500                .title(" ◈ Order Book (no data) ")
3501                .borders(Borders::ALL)
3502                .border_style(Style::new().fg(Color::DarkGray));
3503            f.render_widget(block, area);
3504            return;
3505        }
3506    };
3507
3508    let inner_height = area.height.saturating_sub(2) as usize; // minus borders
3509    if inner_height < 3 {
3510        return;
3511    }
3512
3513    // Allocate rows: half for asks, 1 for spread, half for bids
3514    let ask_rows = (inner_height.saturating_sub(1)) / 2;
3515    let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
3516
3517    // Find max quantity for bar scaling
3518    let max_qty = book
3519        .asks
3520        .iter()
3521        .chain(book.bids.iter())
3522        .map(|l| l.quantity)
3523        .fold(0.0_f64, f64::max)
3524        .max(0.001);
3525
3526    let inner_width = area.width.saturating_sub(2) as usize;
3527    // Bar takes ~30% of width, rest is price/qty text
3528    let bar_width_max = (inner_width as f64 * 0.3).round() as usize;
3529
3530    let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
3531
3532    // --- Ask side (show in reverse so lowest ask is nearest the spread) ---
3533    let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
3534    // Pad with empty lines if fewer asks than rows
3535    for _ in 0..ask_rows.saturating_sub(visible_asks.len()) {
3536        lines.push(Line::from(""));
3537    }
3538    for level in visible_asks.iter().rev() {
3539        let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
3540        let bar = "█".repeat(bar_len);
3541        let price_str = format!("{:.6}", level.price);
3542        let qty_str = format_number(level.quantity);
3543        let val_str = format_number(level.value());
3544        let padding = inner_width
3545            .saturating_sub(bar_len)
3546            .saturating_sub(price_str.len())
3547            .saturating_sub(qty_str.len())
3548            .saturating_sub(val_str.len())
3549            .saturating_sub(4); // spaces between columns
3550        lines.push(Line::from(vec![
3551            Span::styled(bar, Style::new().fg(pal.down).dim()),
3552            Span::raw(" "),
3553            Span::styled(price_str, Style::new().fg(pal.down)),
3554            Span::raw(" ".repeat(padding.max(1))),
3555            Span::styled(qty_str, Style::new().fg(pal.neutral)),
3556            Span::raw(" "),
3557            Span::styled(val_str, Style::new().fg(Color::DarkGray)),
3558        ]));
3559    }
3560
3561    // --- Spread line ---
3562    let spread = book
3563        .best_ask()
3564        .zip(book.best_bid())
3565        .map(|(ask, bid)| {
3566            let s = ask - bid;
3567            let pct = if bid > 0.0 { (s / bid) * 100.0 } else { 0.0 };
3568            format!("  Spread: {:.6} ({:.3}%)", s, pct)
3569        })
3570        .unwrap_or_else(|| "  Spread: --".to_string());
3571    lines.push(Line::from(Span::styled(
3572        spread,
3573        Style::new().fg(Color::Yellow).bold(),
3574    )));
3575
3576    // --- Bid side ---
3577    for level in book.bids.iter().take(bid_rows) {
3578        let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
3579        let bar = "█".repeat(bar_len);
3580        let price_str = format!("{:.6}", level.price);
3581        let qty_str = format_number(level.quantity);
3582        let val_str = format_number(level.value());
3583        let padding = inner_width
3584            .saturating_sub(bar_len)
3585            .saturating_sub(price_str.len())
3586            .saturating_sub(qty_str.len())
3587            .saturating_sub(val_str.len())
3588            .saturating_sub(4);
3589        lines.push(Line::from(vec![
3590            Span::styled(bar, Style::new().fg(pal.up).dim()),
3591            Span::raw(" "),
3592            Span::styled(price_str, Style::new().fg(pal.up)),
3593            Span::raw(" ".repeat(padding.max(1))),
3594            Span::styled(qty_str, Style::new().fg(pal.neutral)),
3595            Span::raw(" "),
3596            Span::styled(val_str, Style::new().fg(Color::DarkGray)),
3597        ]));
3598    }
3599
3600    let ask_depth: f64 = book.asks.iter().map(|l| l.value()).sum();
3601    let bid_depth: f64 = book.bids.iter().map(|l| l.value()).sum();
3602    let title = format!(
3603        " ◈ {} │ Ask {} │ Bid {} ",
3604        book.pair,
3605        format_number(ask_depth),
3606        format_number(bid_depth),
3607    );
3608
3609    let block = Block::default()
3610        .title(title)
3611        .borders(Borders::ALL)
3612        .border_style(Style::new().fg(pal.border));
3613
3614    let paragraph = Paragraph::new(lines).block(block);
3615    f.render_widget(paragraph, area);
3616}
3617
3618/// Renders the recent trades panel for the Exchange layout.
3619///
3620/// Displays a scrolling list of recent trades with time, side (buy/sell),
3621/// price, and quantity. Buy trades are green, sell trades are red.
3622fn render_recent_trades_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3623    let pal = state.palette();
3624
3625    if state.recent_trades.is_empty() {
3626        let block = Block::default()
3627            .title(" ◈ Recent Trades (no data) ")
3628            .borders(Borders::ALL)
3629            .border_style(Style::new().fg(Color::DarkGray));
3630        f.render_widget(block, area);
3631        return;
3632    }
3633
3634    let inner_height = area.height.saturating_sub(2) as usize;
3635    let inner_width = area.width.saturating_sub(2) as usize;
3636
3637    // Column widths
3638    let time_width = 8; // HH:MM:SS
3639    let side_width = 4; // BUY / SELL
3640    let price_width = inner_width
3641        .saturating_sub(time_width)
3642        .saturating_sub(side_width)
3643        .saturating_sub(3) // separators
3644        / 2;
3645    let qty_width = inner_width
3646        .saturating_sub(time_width)
3647        .saturating_sub(side_width)
3648        .saturating_sub(price_width)
3649        .saturating_sub(3);
3650
3651    let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
3652
3653    // Header row
3654    lines.push(Line::from(vec![
3655        Span::styled(
3656            format!("{:<time_width$}", "Time"),
3657            Style::new().fg(Color::DarkGray).bold(),
3658        ),
3659        Span::raw(" "),
3660        Span::styled(
3661            format!("{:<side_width$}", "Side"),
3662            Style::new().fg(Color::DarkGray).bold(),
3663        ),
3664        Span::raw(" "),
3665        Span::styled(
3666            format!("{:>price_width$}", "Price"),
3667            Style::new().fg(Color::DarkGray).bold(),
3668        ),
3669        Span::raw(" "),
3670        Span::styled(
3671            format!("{:>qty_width$}", "Qty"),
3672            Style::new().fg(Color::DarkGray).bold(),
3673        ),
3674    ]));
3675
3676    // Trade rows (most recent first)
3677    let visible_count = inner_height.saturating_sub(1); // minus header
3678    for trade in state.recent_trades.iter().rev().take(visible_count) {
3679        let (side_str, side_color) = match trade.side {
3680            TradeSide::Buy => ("BUY ", pal.up),
3681            TradeSide::Sell => ("SELL", pal.down),
3682        };
3683
3684        // Format timestamp (HH:MM:SS from epoch ms)
3685        let secs = (trade.timestamp_ms / 1000) as i64;
3686        let hours = (secs / 3600) % 24;
3687        let mins = (secs / 60) % 60;
3688        let sec = secs % 60;
3689        let time_str = format!("{:02}:{:02}:{:02}", hours, mins, sec);
3690
3691        let price_str = if trade.price >= 1000.0 {
3692            format!("{:.2}", trade.price)
3693        } else if trade.price >= 1.0 {
3694            format!("{:.4}", trade.price)
3695        } else {
3696            format!("{:.6}", trade.price)
3697        };
3698
3699        let qty_str = format_number(trade.quantity);
3700
3701        lines.push(Line::from(vec![
3702            Span::styled(
3703                format!("{:<time_width$}", time_str),
3704                Style::new().fg(Color::DarkGray),
3705            ),
3706            Span::raw(" "),
3707            Span::styled(
3708                format!("{:<side_width$}", side_str),
3709                Style::new().fg(side_color),
3710            ),
3711            Span::raw(" "),
3712            Span::styled(
3713                format!("{:>price_width$}", price_str),
3714                Style::new().fg(side_color),
3715            ),
3716            Span::raw(" "),
3717            Span::styled(
3718                format!("{:>qty_width$}", qty_str),
3719                Style::new().fg(pal.neutral),
3720            ),
3721        ]));
3722    }
3723
3724    let title = format!(" ◈ Recent Trades ({}) ", state.recent_trades.len());
3725    let block = Block::default()
3726        .title(title)
3727        .borders(Borders::ALL)
3728        .border_style(Style::new().fg(pal.border));
3729
3730    let paragraph = Paragraph::new(lines).block(block);
3731    f.render_widget(paragraph, area);
3732}
3733
3734/// Renders the market info panel for the Exchange layout.
3735///
3736/// Shows per-pair breakdown (DEX, volume, liquidity), 24h stats,
3737/// token metadata (links, creation date), and aggregated metrics.
3738fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3739    let pal = state.palette();
3740
3741    // Split into left (pair table) and right (token info) columns
3742    let cols = Layout::default()
3743        .direction(Direction::Horizontal)
3744        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
3745        .split(area);
3746
3747    // ── Left: Trading pairs table ──
3748    {
3749        let header = Row::new(vec!["DEX / Pair", "Volume 24h", "Liquidity", "Δ 24h"])
3750            .style(Style::new().fg(Color::Cyan).bold())
3751            .bottom_margin(0);
3752
3753        let rows: Vec<Row> = state
3754            .dex_pairs
3755            .iter()
3756            .take(cols[0].height.saturating_sub(3) as usize) // fit available rows
3757            .map(|p| {
3758                let pair_label = format!("{}/{}", p.base_token, p.quote_token);
3759                let dex_and_pair = format!("{} {}", p.dex_name, pair_label);
3760                let vol = format_number(p.volume_24h);
3761                let liq = format_number(p.liquidity_usd);
3762                let change_str = format!("{:+.1}%", p.price_change_24h);
3763                let change_color = if p.price_change_24h >= 0.0 {
3764                    pal.up
3765                } else {
3766                    pal.down
3767                };
3768                Row::new(vec![
3769                    ratatui::text::Text::from(dex_and_pair),
3770                    ratatui::text::Text::styled(vol, Style::new().fg(pal.neutral)),
3771                    ratatui::text::Text::styled(liq, Style::new().fg(pal.volume_bar)),
3772                    ratatui::text::Text::styled(change_str, Style::new().fg(change_color)),
3773                ])
3774            })
3775            .collect();
3776
3777        let widths = [
3778            Constraint::Percentage(40),
3779            Constraint::Percentage(22),
3780            Constraint::Percentage(22),
3781            Constraint::Percentage(16),
3782        ];
3783
3784        let table = Table::new(rows, widths).header(header).block(
3785            Block::default()
3786                .title(format!(" ◫ Trading Pairs ({}) ", state.dex_pairs.len()))
3787                .borders(Borders::ALL)
3788                .border_style(Style::new().fg(pal.border)),
3789        );
3790
3791        f.render_widget(table, cols[0]);
3792    }
3793
3794    // ── Right: Token info + aggregated metrics ──
3795    {
3796        let mut info_lines: Vec<Line> = Vec::new();
3797
3798        // Price summary
3799        let price_color = if state.price_change_24h >= 0.0 {
3800            pal.up
3801        } else {
3802            pal.down
3803        };
3804        info_lines.push(Line::from(vec![
3805            Span::styled(" Price  ", Style::new().fg(Color::DarkGray)),
3806            Span::styled(
3807                format!("${:.6}", state.current_price),
3808                Style::new().fg(Color::White).bold(),
3809            ),
3810        ]));
3811
3812        // Multi-timeframe changes
3813        let changes = [
3814            ("5m", state.price_change_5m),
3815            ("1h", state.price_change_1h),
3816            ("6h", state.price_change_6h),
3817            ("24h", state.price_change_24h),
3818        ];
3819        let change_spans: Vec<Span> = changes
3820            .iter()
3821            .flat_map(|(label, val)| {
3822                let color = if *val >= 0.0 { pal.up } else { pal.down };
3823                vec![
3824                    Span::styled(format!(" {}: ", label), Style::new().fg(Color::DarkGray)),
3825                    Span::styled(format!("{:+.2}%", val), Style::new().fg(color)),
3826                ]
3827            })
3828            .collect();
3829        info_lines.push(Line::from(change_spans));
3830
3831        info_lines.push(Line::from(""));
3832
3833        // Volume & Liquidity
3834        info_lines.push(Line::from(vec![
3835            Span::styled(" Vol 24h ", Style::new().fg(Color::DarkGray)),
3836            Span::styled(
3837                format!("${}", format_number(state.volume_24h)),
3838                Style::new().fg(pal.neutral),
3839            ),
3840        ]));
3841        info_lines.push(Line::from(vec![
3842            Span::styled(" Liq     ", Style::new().fg(Color::DarkGray)),
3843            Span::styled(
3844                format!("${}", format_number(state.liquidity_usd)),
3845                Style::new().fg(pal.volume_bar),
3846            ),
3847        ]));
3848
3849        // Market cap / FDV
3850        if let Some(mc) = state.market_cap {
3851            info_lines.push(Line::from(vec![
3852                Span::styled(" MCap    ", Style::new().fg(Color::DarkGray)),
3853                Span::styled(
3854                    format!("${}", format_number(mc)),
3855                    Style::new().fg(pal.neutral),
3856                ),
3857            ]));
3858        }
3859        if let Some(fdv) = state.fdv {
3860            info_lines.push(Line::from(vec![
3861                Span::styled(" FDV     ", Style::new().fg(Color::DarkGray)),
3862                Span::styled(
3863                    format!("${}", format_number(fdv)),
3864                    Style::new().fg(pal.neutral),
3865                ),
3866            ]));
3867        }
3868
3869        // Buy/sell stats
3870        info_lines.push(Line::from(""));
3871        let total_txs = state.buys_24h + state.sells_24h;
3872        let buy_pct = if total_txs > 0 {
3873            (state.buys_24h as f64 / total_txs as f64) * 100.0
3874        } else {
3875            50.0
3876        };
3877        info_lines.push(Line::from(vec![
3878            Span::styled(" Buys    ", Style::new().fg(Color::DarkGray)),
3879            Span::styled(
3880                format!("{} ({:.0}%)", state.buys_24h, buy_pct),
3881                Style::new().fg(pal.up),
3882            ),
3883        ]));
3884        info_lines.push(Line::from(vec![
3885            Span::styled(" Sells   ", Style::new().fg(Color::DarkGray)),
3886            Span::styled(
3887                format!("{} ({:.0}%)", state.sells_24h, 100.0 - buy_pct),
3888                Style::new().fg(pal.down),
3889            ),
3890        ]));
3891
3892        // Holder count
3893        if let Some(holders) = state.holder_count {
3894            info_lines.push(Line::from(vec![
3895                Span::styled(" Holders ", Style::new().fg(Color::DarkGray)),
3896                Span::styled(format_number(holders as f64), Style::new().fg(pal.neutral)),
3897            ]));
3898        }
3899
3900        // Listed since
3901        if let Some(ts) = state.earliest_pair_created_at {
3902            let dt = chrono::DateTime::from_timestamp(ts, 0)
3903                .map(|d| d.format("%Y-%m-%d").to_string())
3904                .unwrap_or_else(|| "?".to_string());
3905            info_lines.push(Line::from(vec![
3906                Span::styled(" Listed  ", Style::new().fg(Color::DarkGray)),
3907                Span::styled(dt, Style::new().fg(pal.neutral)),
3908            ]));
3909        }
3910
3911        // Links
3912        if !state.websites.is_empty() || !state.socials.is_empty() {
3913            info_lines.push(Line::from(""));
3914            let mut link_spans = vec![Span::styled(" Links   ", Style::new().fg(Color::DarkGray))];
3915            for (platform, _url) in &state.socials {
3916                link_spans.push(Span::styled(
3917                    format!("[{}] ", platform),
3918                    Style::new().fg(Color::Cyan),
3919                ));
3920            }
3921            for url in &state.websites {
3922                // Show domain only
3923                let domain = url
3924                    .trim_start_matches("https://")
3925                    .trim_start_matches("http://")
3926                    .split('/')
3927                    .next()
3928                    .unwrap_or(url);
3929                link_spans.push(Span::styled(
3930                    format!("[{}] ", domain),
3931                    Style::new().fg(Color::Blue),
3932                ));
3933            }
3934            info_lines.push(Line::from(link_spans));
3935        }
3936
3937        let title = format!(" ◉ {} ({}) ", state.symbol, state.name);
3938        let block = Block::default()
3939            .title(title)
3940            .borders(Borders::ALL)
3941            .border_style(Style::new().fg(price_color));
3942
3943        let paragraph = Paragraph::new(info_lines).block(block);
3944        f.render_widget(paragraph, cols[1]);
3945    }
3946}
3947
3948/// Renders the footer with status and controls.
3949fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
3950    let elapsed = state.last_update.elapsed().as_secs();
3951
3952    // Calculate time since last price change
3953    let now_ts = chrono::Utc::now().timestamp() as f64;
3954    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3955    let price_change_str = if secs_since_change < 60 {
3956        format!("{}s", secs_since_change)
3957    } else if secs_since_change < 3600 {
3958        format!("{}m", secs_since_change / 60)
3959    } else {
3960        format!("{}h", secs_since_change / 3600)
3961    };
3962
3963    // Get data stats
3964    let (synthetic_count, real_count) = state.data_stats();
3965    let memory_bytes = state.memory_usage();
3966    let memory_str = if memory_bytes >= 1024 * 1024 {
3967        format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
3968    } else if memory_bytes >= 1024 {
3969        format!("{:.1}KB", memory_bytes as f64 / 1024.0)
3970    } else {
3971        format!("{}B", memory_bytes)
3972    };
3973
3974    let status = if let Some(ref err) = state.error_message {
3975        Span::styled(format!("⚠ {}", err), Style::new().red())
3976    } else if state.paused {
3977        Span::styled("⏸ PAUSED", Style::new().fg(Color::Yellow).bold())
3978    } else if state.is_auto_paused() {
3979        Span::styled("⏸ AUTO-PAUSED", Style::new().fg(Color::Cyan).bold())
3980    } else {
3981        Span::styled(
3982            format!(
3983                "↻ {}s │ Δ {} │ {} pts │ {}",
3984                elapsed,
3985                price_change_str,
3986                synthetic_count + real_count,
3987                memory_str
3988            ),
3989            Style::new().gray(),
3990        )
3991    };
3992
3993    let widget_hint = if state.widget_toggle_mode {
3994        Span::styled("W:1-5?", Style::new().fg(Color::Yellow).bold())
3995    } else {
3996        Span::styled("W", Style::new().fg(Color::Cyan).bold())
3997    };
3998
3999    let mut spans = vec![status, Span::raw(" ║ ")];
4000
4001    // REC indicator when CSV export is active
4002    if state.export_active {
4003        spans.push(Span::styled("● REC ", Style::new().fg(Color::Red).bold()));
4004    }
4005
4006    spans.extend([
4007        Span::styled("Q", Style::new().red().bold()),
4008        Span::raw("uit "),
4009        Span::styled("R", Style::new().fg(Color::Green).bold()),
4010        Span::raw("efresh "),
4011        Span::styled("P", Style::new().fg(Color::Yellow).bold()),
4012        Span::raw("ause "),
4013        Span::styled("E", Style::new().fg(Color::LightRed).bold()),
4014        Span::raw("xport "),
4015        Span::styled("L", Style::new().fg(Color::Cyan).bold()),
4016        Span::raw(format!(":{} ", state.layout.label())),
4017        widget_hint,
4018        Span::raw("idget "),
4019        Span::styled("C", Style::new().fg(Color::LightBlue).bold()),
4020        Span::raw(format!("hart:{} ", state.chart_mode.label())),
4021        Span::styled("S", Style::new().fg(Color::LightGreen).bold()),
4022        Span::raw(format!("cale:{} ", state.scale_mode.label())),
4023        Span::styled("/", Style::new().fg(Color::LightRed).bold()),
4024        Span::raw(format!(":{} ", state.color_scheme.label())),
4025        Span::styled("T", Style::new().fg(Color::Magenta).bold()),
4026        Span::raw("ime "),
4027    ]);
4028
4029    let footer = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
4030
4031    f.render_widget(footer, area);
4032}
4033
4034/// Formats a number with K/M/B suffixes.
4035fn format_number(n: f64) -> String {
4036    if n >= 1_000_000_000.0 {
4037        format!("{:.2}B", n / 1_000_000_000.0)
4038    } else if n >= 1_000_000.0 {
4039        format!("{:.2}M", n / 1_000_000.0)
4040    } else if n >= 1_000.0 {
4041        format!("{:.2}K", n / 1_000.0)
4042    } else {
4043        format!("{:.2}", n)
4044    }
4045}
4046
4047/// Entry point for the top-level `scope monitor` command.
4048///
4049/// Creates a `SessionContext` from CLI args and delegates to [`run`].
4050/// Applies CLI-provided overrides (layout, refresh, scale, etc.) on top
4051/// of the config-file defaults.
4052pub async fn run_direct(
4053    mut args: MonitorArgs,
4054    config: &Config,
4055    clients: &dyn ChainClientFactory,
4056) -> Result<()> {
4057    // Resolve address book label → address + chain
4058    if let Some((address, chain)) =
4059        crate::cli::address_book::resolve_address_book_input(&args.token, config)?
4060    {
4061        args.token = address;
4062        if args.chain == "ethereum" {
4063            args.chain = chain;
4064        }
4065    }
4066
4067    // Build a SessionContext from the CLI args (no interactive session needed)
4068    let ctx = SessionContext {
4069        chain: args.chain,
4070        ..SessionContext::default()
4071    };
4072
4073    // Build a MonitorConfig from config-file defaults + CLI overrides
4074    let mut monitor_config = config.monitor.clone();
4075    if let Some(layout) = args.layout {
4076        monitor_config.layout = layout;
4077    }
4078    if let Some(refresh) = args.refresh {
4079        monitor_config.refresh_seconds = refresh;
4080    }
4081    if let Some(scale) = args.scale {
4082        monitor_config.scale = scale;
4083    }
4084    if let Some(color_scheme) = args.color_scheme {
4085        monitor_config.color_scheme = color_scheme;
4086    }
4087    if let Some(ref path) = args.export {
4088        monitor_config.export.path = Some(path.to_string_lossy().into_owned());
4089    }
4090    if let Some(ref venue) = args.venue {
4091        monitor_config.venue = Some(venue.clone());
4092    }
4093
4094    // Use a temporary Config with the CLI-overridden monitor settings
4095    let mut effective_config = config.clone();
4096    effective_config.monitor = monitor_config;
4097
4098    run(
4099        Some(args.token),
4100        args.pair,
4101        &ctx,
4102        &effective_config,
4103        clients,
4104    )
4105    .await
4106}
4107
4108/// Entry point for the monitor command from interactive mode.
4109///
4110/// When `explicit_pair` is `Some`, token resolution is bypassed and the
4111/// exchange ticker is used as the primary data source.  This enables
4112/// monitoring tokens that are not indexed by DexScreener (e.g., PUSD on
4113/// Biconomy).
4114pub async fn run(
4115    token: Option<String>,
4116    explicit_pair: Option<String>,
4117    ctx: &SessionContext,
4118    config: &Config,
4119    clients: &dyn ChainClientFactory,
4120) -> Result<()> {
4121    let token_input = match token {
4122        Some(t) => t,
4123        None => {
4124            return Err(ScopeError::Chain(
4125                "Token address or symbol required. Usage: monitor <token>".to_string(),
4126            ));
4127        }
4128    };
4129
4130    eprintln!("  Starting live monitor for {}...", token_input);
4131    eprintln!("  Fetching initial data...");
4132
4133    // Create exchange client if a venue is configured (from MonitorConfig or CLI)
4134    let exchange_client = config.monitor.venue.as_ref().and_then(|venue_id| {
4135        crate::market::VenueRegistry::load()
4136            .ok()
4137            .and_then(|r| r.get(venue_id).cloned())
4138            .map(|desc| crate::market::ExchangeClient::from_descriptor(&desc))
4139    });
4140
4141    // ---- Exchange-only mode (--pair + --venue) ----
4142    // Bypass DexScreener entirely when the user provides a direct pair.
4143    let initial_data = if let Some(ref pair_str) = explicit_pair {
4144        let ex = exchange_client.as_ref().ok_or_else(|| {
4145            ScopeError::Chain("--pair requires --venue to be specified".to_string())
4146        })?;
4147
4148        // Fetch the ticker to get current price data
4149        let ticker = ex.fetch_ticker(pair_str).await.map_err(|e| {
4150            ScopeError::Chain(format!("Failed to fetch ticker for {}: {}", pair_str, e))
4151        })?;
4152
4153        // Extract base symbol from pair label (e.g., "PUSD/USDT" → "PUSD")
4154        let base_symbol = ticker
4155            .pair
4156            .split('/')
4157            .next()
4158            .unwrap_or(&token_input)
4159            .to_string();
4160
4161        eprintln!(
4162            "  Exchange-only mode: {} @ ${:.6}",
4163            pair_str,
4164            ticker.last_price.unwrap_or(0.0)
4165        );
4166
4167        // Build a minimal DexTokenData from the exchange ticker
4168        build_exchange_token_data(&base_symbol, pair_str, &ticker)
4169    } else {
4170        // ---- Normal DexScreener mode ----
4171        let dex_client = clients.create_dex_client();
4172        let token_address =
4173            resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
4174
4175        dex_client
4176            .get_token_data(&ctx.chain, &token_address)
4177            .await?
4178    };
4179
4180    println!(
4181        "Monitoring {} ({}) on {}",
4182        initial_data.symbol, initial_data.name, ctx.chain
4183    );
4184    println!("Press Q to quit, R to refresh, P to pause...\n");
4185
4186    // Small delay to let user read the message
4187    tokio::time::sleep(Duration::from_millis(500)).await;
4188
4189    // Create optional chain client for on-chain data (holder count, etc.)
4190    let chain_client = clients.create_chain_client(&ctx.chain).ok();
4191
4192    // Create and run the app — override venue_pair when using explicit pair mode
4193    let mut app = MonitorApp::new(
4194        initial_data,
4195        &ctx.chain,
4196        &config.monitor,
4197        chain_client,
4198        exchange_client,
4199    )?;
4200
4201    // In explicit-pair mode, override the auto-formatted pair with the user's exact pair
4202    if let Some(ref pair_str) = explicit_pair {
4203        app.state.venue_pair = Some(pair_str.clone());
4204    }
4205
4206    let result = app.run().await;
4207
4208    // Cleanup is handled by Drop, but we do it explicitly for error handling
4209    if let Err(e) = app.cleanup() {
4210        eprintln!("Warning: Failed to cleanup terminal: {}", e);
4211    }
4212
4213    result
4214}
4215
4216/// Builds a minimal [`DexTokenData`] from an exchange ticker.
4217///
4218/// Used in exchange-only mode (--pair) when DexScreener is not available
4219/// for the token.
4220fn build_exchange_token_data(
4221    symbol: &str,
4222    pair_label: &str,
4223    ticker: &crate::market::Ticker,
4224) -> DexTokenData {
4225    let price = ticker.last_price.unwrap_or(0.0);
4226    DexTokenData {
4227        address: format!("exchange:{}", pair_label),
4228        symbol: symbol.to_string(),
4229        name: symbol.to_string(),
4230        price_usd: price,
4231        price_change_24h: 0.0,
4232        price_change_6h: 0.0,
4233        price_change_1h: 0.0,
4234        price_change_5m: 0.0,
4235        volume_24h: ticker.volume_24h.unwrap_or(0.0),
4236        volume_6h: 0.0,
4237        volume_1h: 0.0,
4238        liquidity_usd: 0.0,
4239        market_cap: None,
4240        fdv: None,
4241        pairs: vec![],
4242        price_history: vec![],
4243        volume_history: vec![],
4244        total_buys_24h: 0,
4245        total_sells_24h: 0,
4246        total_buys_6h: 0,
4247        total_sells_6h: 0,
4248        total_buys_1h: 0,
4249        total_sells_1h: 0,
4250        earliest_pair_created_at: None,
4251        image_url: None,
4252        websites: vec![],
4253        socials: vec![],
4254        dexscreener_url: None,
4255    }
4256}
4257
4258/// Resolves a token input (address or symbol) to an address.
4259///
4260/// Uses the same chain filter logic as the crawl command: when the chain is
4261/// "ethereum" (the default), searches ALL chains so that exact symbol matches
4262/// on other chains rank above substring matches on ethereum.  Includes a CEX
4263/// ticker fallback when DexScreener returns no results.
4264async fn resolve_token_address(
4265    input: &str,
4266    chain: &str,
4267    _config: &Config,
4268    dex_client: &dyn DexDataSource,
4269) -> Result<String> {
4270    // Check if it's already an address (EVM, Solana, Tron)
4271    if crate::tokens::TokenAliases::is_address(input) {
4272        return Ok(input.to_string());
4273    }
4274
4275    // Check saved aliases — use chain filter only when explicitly overridden
4276    let chain_filter = if chain != "ethereum" {
4277        Some(chain)
4278    } else {
4279        None
4280    };
4281    let aliases = crate::tokens::TokenAliases::load();
4282    if let Some(alias) = aliases.get(input, chain_filter) {
4283        return Ok(alias.address.clone());
4284    }
4285
4286    // Search by name/symbol — same filter logic as crawl:
4287    // "ethereum" (default) → None (all chains), explicit chain → Some(chain)
4288    let mut results = dex_client.search_tokens(input, chain_filter).await?;
4289
4290    // CEX fallback: if DexScreener has no results, try exchange ticker
4291    if results.is_empty()
4292        && let Some(fallback) = try_cex_fallback(input, chain).await
4293    {
4294        eprintln!(
4295            "  Not found on DexScreener; found {} on {} (CEX)",
4296            fallback.symbol, fallback.chain
4297        );
4298        results.push(fallback);
4299    }
4300
4301    if results.is_empty() {
4302        return Err(ScopeError::NotFound(format!(
4303            "No token found matching '{}' on {} (checked DexScreener and CEX venues)",
4304            input, chain
4305        )));
4306    }
4307
4308    // If only one result, use it directly
4309    if results.len() == 1 {
4310        let token = &results[0];
4311        println!(
4312            "Found: {} ({}) - ${:.6}",
4313            token.symbol,
4314            token.name,
4315            token.price_usd.unwrap_or(0.0)
4316        );
4317        return Ok(token.address.clone());
4318    }
4319
4320    // Multiple results — prompt user to select
4321    let selected = select_token_interactive(&results)?;
4322    Ok(selected.address.clone())
4323}
4324
4325/// CEX venue ticker fallback for token resolution.
4326///
4327/// When DexScreener returns no results, tries to find the token on a
4328/// centralized exchange (Binance) as a fallback. Returns a synthetic
4329/// `TokenSearchResult` from the ticker data.
4330async fn try_cex_fallback(symbol: &str, chain: &str) -> Option<crate::chains::TokenSearchResult> {
4331    let registry = crate::market::VenueRegistry::load().ok()?;
4332    let descriptor = registry.get("binance")?;
4333    let client = crate::market::ExchangeClient::from_descriptor(&descriptor.clone());
4334    let pair = client.format_pair(&format!("{}USDT", symbol.to_uppercase()));
4335    let ticker = client.fetch_ticker(&pair).await.ok()?;
4336    let price = ticker.last_price.unwrap_or(0.0);
4337    Some(crate::chains::TokenSearchResult {
4338        address: String::new(),
4339        symbol: symbol.to_uppercase(),
4340        name: symbol.to_uppercase(),
4341        chain: chain.to_string(),
4342        price_usd: Some(price),
4343        volume_24h: ticker.volume_24h.unwrap_or(0.0),
4344        liquidity_usd: 0.0,
4345        market_cap: None,
4346    })
4347}
4348
4349/// Abbreviates a blockchain address for display (e.g. "0x1234...abcd").
4350fn abbreviate_address(addr: &str) -> String {
4351    if addr.len() > 16 {
4352        format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
4353    } else {
4354        addr.to_string()
4355    }
4356}
4357
4358/// Displays token search results and prompts the user to select one.
4359fn select_token_interactive(
4360    results: &[crate::chains::dex::TokenSearchResult],
4361) -> Result<&crate::chains::dex::TokenSearchResult> {
4362    let stdin = io::stdin();
4363    let stdout = io::stdout();
4364    select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
4365}
4366
4367/// Testable implementation of token selection with injected I/O.
4368fn select_token_impl<'a>(
4369    results: &'a [crate::chains::dex::TokenSearchResult],
4370    reader: &mut impl io::BufRead,
4371    writer: &mut impl io::Write,
4372) -> Result<&'a crate::chains::dex::TokenSearchResult> {
4373    writeln!(
4374        writer,
4375        "\nFound {} tokens matching your query:\n",
4376        results.len()
4377    )
4378    .map_err(|e| ScopeError::Io(e.to_string()))?;
4379
4380    writeln!(
4381        writer,
4382        "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
4383        "#", "Symbol", "Name", "Address", "Price", "Liquidity"
4384    )
4385    .map_err(|e| ScopeError::Io(e.to_string()))?;
4386
4387    writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
4388
4389    for (i, token) in results.iter().enumerate() {
4390        let price = token
4391            .price_usd
4392            .map(|p| format!("${:.6}", p))
4393            .unwrap_or_else(|| "N/A".to_string());
4394
4395        let liquidity = format_monitor_number(token.liquidity_usd);
4396        let addr = abbreviate_address(&token.address);
4397
4398        // Truncate name if too long
4399        let name = if token.name.len() > 20 {
4400            format!("{}...", &token.name[..17])
4401        } else {
4402            token.name.clone()
4403        };
4404
4405        writeln!(
4406            writer,
4407            "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
4408            i + 1,
4409            token.symbol,
4410            name,
4411            addr,
4412            price,
4413            liquidity
4414        )
4415        .map_err(|e| ScopeError::Io(e.to_string()))?;
4416    }
4417
4418    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
4419    write!(writer, "Select token (1-{}): ", results.len())
4420        .map_err(|e| ScopeError::Io(e.to_string()))?;
4421    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
4422
4423    let mut input = String::new();
4424    reader
4425        .read_line(&mut input)
4426        .map_err(|e| ScopeError::Io(e.to_string()))?;
4427
4428    let selection: usize = input
4429        .trim()
4430        .parse()
4431        .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
4432
4433    if selection < 1 || selection > results.len() {
4434        return Err(ScopeError::Api(format!(
4435            "Selection must be between 1 and {}",
4436            results.len()
4437        )));
4438    }
4439
4440    let selected = &results[selection - 1];
4441    writeln!(
4442        writer,
4443        "Selected: {} ({}) at {}",
4444        selected.symbol, selected.name, selected.address
4445    )
4446    .map_err(|e| ScopeError::Io(e.to_string()))?;
4447
4448    Ok(selected)
4449}
4450
4451/// Format a number for the monitor selection table.
4452fn format_monitor_number(value: f64) -> String {
4453    if value >= 1_000_000_000.0 {
4454        format!("${:.2}B", value / 1_000_000_000.0)
4455    } else if value >= 1_000_000.0 {
4456        format!("${:.2}M", value / 1_000_000.0)
4457    } else if value >= 1_000.0 {
4458        format!("${:.2}K", value / 1_000.0)
4459    } else {
4460        format!("${:.2}", value)
4461    }
4462}
4463
4464// ============================================================================
4465// Unit Tests
4466// ============================================================================
4467
4468#[cfg(test)]
4469mod tests {
4470    use super::*;
4471
4472    fn create_test_token_data() -> DexTokenData {
4473        DexTokenData {
4474            address: "0x1234".to_string(),
4475            symbol: "TEST".to_string(),
4476            name: "Test Token".to_string(),
4477            price_usd: 1.0,
4478            price_change_24h: 5.0,
4479            price_change_6h: 2.0,
4480            price_change_1h: 0.5,
4481            price_change_5m: 0.1,
4482            volume_24h: 1_000_000.0,
4483            volume_6h: 250_000.0,
4484            volume_1h: 50_000.0,
4485            liquidity_usd: 500_000.0,
4486            market_cap: Some(10_000_000.0),
4487            fdv: Some(100_000_000.0),
4488            pairs: vec![],
4489            price_history: vec![],
4490            volume_history: vec![],
4491            total_buys_24h: 100,
4492            total_sells_24h: 50,
4493            total_buys_6h: 25,
4494            total_sells_6h: 12,
4495            total_buys_1h: 5,
4496            total_sells_1h: 3,
4497            earliest_pair_created_at: Some(1700000000000),
4498            image_url: None,
4499            websites: vec![],
4500            socials: vec![],
4501            dexscreener_url: None,
4502        }
4503    }
4504
4505    #[test]
4506    fn test_monitor_state_new() {
4507        let token_data = create_test_token_data();
4508        let state = MonitorState::new(&token_data, "ethereum");
4509
4510        assert_eq!(state.symbol, "TEST");
4511        assert_eq!(state.chain, "ethereum");
4512        assert_eq!(state.current_price, 1.0);
4513        assert_eq!(state.buys_24h, 100);
4514        assert_eq!(state.sells_24h, 50);
4515        assert!(!state.paused);
4516    }
4517
4518    #[test]
4519    fn test_monitor_state_buy_ratio() {
4520        let token_data = create_test_token_data();
4521        let state = MonitorState::new(&token_data, "ethereum");
4522
4523        let ratio = state.buy_ratio();
4524        assert!((ratio - 0.6666).abs() < 0.01); // 100 / 150 ≈ 0.667
4525    }
4526
4527    #[test]
4528    fn test_monitor_state_buy_ratio_zero() {
4529        let mut token_data = create_test_token_data();
4530        token_data.total_buys_24h = 0;
4531        token_data.total_sells_24h = 0;
4532        let state = MonitorState::new(&token_data, "ethereum");
4533
4534        assert_eq!(state.buy_ratio(), 0.5); // Default to 50/50 when no data
4535    }
4536
4537    #[test]
4538    fn test_monitor_state_toggle_pause() {
4539        let token_data = create_test_token_data();
4540        let mut state = MonitorState::new(&token_data, "ethereum");
4541
4542        assert!(!state.paused);
4543        state.toggle_pause();
4544        assert!(state.paused);
4545        state.toggle_pause();
4546        assert!(!state.paused);
4547    }
4548
4549    #[test]
4550    fn test_monitor_state_should_refresh() {
4551        let token_data = create_test_token_data();
4552        let mut state = MonitorState::new(&token_data, "ethereum");
4553        state.refresh_rate = Duration::from_secs(60);
4554
4555        // Just created, should not need refresh (60s refresh rate)
4556        assert!(!state.should_refresh());
4557
4558        // Simulate time passing well beyond refresh rate
4559        state.last_update = Instant::now() - Duration::from_secs(120);
4560        assert!(state.should_refresh());
4561
4562        // Pause should prevent refresh
4563        state.paused = true;
4564        assert!(!state.should_refresh());
4565    }
4566
4567    #[test]
4568    fn test_format_number() {
4569        assert_eq!(format_number(500.0), "500.00");
4570        assert_eq!(format_number(1_500.0), "1.50K");
4571        assert_eq!(format_number(1_500_000.0), "1.50M");
4572        assert_eq!(format_number(1_500_000_000.0), "1.50B");
4573    }
4574
4575    #[test]
4576    fn test_format_usd() {
4577        assert_eq!(crate::display::format_usd(500.0), "$500.00");
4578        assert_eq!(crate::display::format_usd(1_500.0), "$1.50K");
4579        assert_eq!(crate::display::format_usd(1_500_000.0), "$1.50M");
4580        assert_eq!(crate::display::format_usd(1_500_000_000.0), "$1.50B");
4581    }
4582
4583    #[test]
4584    fn test_monitor_state_update() {
4585        let token_data = create_test_token_data();
4586        let mut state = MonitorState::new(&token_data, "ethereum");
4587
4588        let initial_len = state.price_history.len();
4589
4590        let mut updated_data = token_data.clone();
4591        updated_data.price_usd = 1.5;
4592        updated_data.total_buys_24h = 150;
4593
4594        state.update(&updated_data);
4595
4596        assert_eq!(state.current_price, 1.5);
4597        assert_eq!(state.buys_24h, 150);
4598        // Should have one more point after update
4599        assert_eq!(state.price_history.len(), initial_len + 1);
4600    }
4601
4602    #[test]
4603    fn test_monitor_state_refresh_rate_adjustment() {
4604        let token_data = create_test_token_data();
4605        let mut state = MonitorState::new(&token_data, "ethereum");
4606
4607        // Default is 5 seconds
4608        assert_eq!(state.refresh_rate_secs(), 5);
4609
4610        // Slow down (+5s)
4611        state.slower_refresh();
4612        assert_eq!(state.refresh_rate_secs(), 10);
4613
4614        // Speed up (-5s)
4615        state.faster_refresh();
4616        assert_eq!(state.refresh_rate_secs(), 5);
4617
4618        // Speed up again (should hit minimum of 1s)
4619        state.faster_refresh();
4620        assert_eq!(state.refresh_rate_secs(), 1);
4621
4622        // Can't go below 1s
4623        state.faster_refresh();
4624        assert_eq!(state.refresh_rate_secs(), 1);
4625
4626        // Slow down to max (60s)
4627        for _ in 0..20 {
4628            state.slower_refresh();
4629        }
4630        assert_eq!(state.refresh_rate_secs(), 60);
4631    }
4632
4633    #[test]
4634    fn test_time_period() {
4635        assert_eq!(TimePeriod::Min1.label(), "1m");
4636        assert_eq!(TimePeriod::Min5.label(), "5m");
4637        assert_eq!(TimePeriod::Min15.label(), "15m");
4638        assert_eq!(TimePeriod::Hour1.label(), "1h");
4639        assert_eq!(TimePeriod::Hour4.label(), "4h");
4640        assert_eq!(TimePeriod::Day1.label(), "1d");
4641
4642        assert_eq!(TimePeriod::Min1.duration_secs(), 60);
4643        assert_eq!(TimePeriod::Min5.duration_secs(), 300);
4644        assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
4645        assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
4646        assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
4647        assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
4648
4649        // Test cycling
4650        assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
4651        assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
4652        assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
4653        assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
4654        assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
4655        assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
4656    }
4657
4658    #[test]
4659    fn test_time_period_exchange_interval() {
4660        assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
4661        assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
4662        assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
4663        assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
4664        assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
4665        assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
4666    }
4667
4668    #[test]
4669    fn test_monitor_state_time_period() {
4670        let token_data = create_test_token_data();
4671        let mut state = MonitorState::new(&token_data, "ethereum");
4672
4673        // Default is 1 hour
4674        assert_eq!(state.time_period, TimePeriod::Hour1);
4675
4676        // Cycle through periods
4677        state.cycle_time_period();
4678        assert_eq!(state.time_period, TimePeriod::Hour4);
4679
4680        state.set_time_period(TimePeriod::Day1);
4681        assert_eq!(state.time_period, TimePeriod::Day1);
4682    }
4683
4684    #[test]
4685    fn test_synthetic_history_generation() {
4686        let token_data = create_test_token_data();
4687        let state = MonitorState::new(&token_data, "ethereum");
4688
4689        // Should have generated history (synthetic or cached real)
4690        assert!(state.price_history.len() > 1);
4691        assert!(state.volume_history.len() > 1);
4692
4693        // Price history should span some time range
4694        if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
4695        {
4696            let span = last.timestamp - first.timestamp;
4697            assert!(span > 0.0); // History should span some time
4698        }
4699    }
4700
4701    #[test]
4702    fn test_real_data_marking() {
4703        let token_data = create_test_token_data();
4704        let mut state = MonitorState::new(&token_data, "ethereum");
4705
4706        // Initially all synthetic
4707        let (synthetic, real) = state.data_stats();
4708        assert!(synthetic > 0);
4709        assert_eq!(real, 0);
4710
4711        // After update, should have real data
4712        let mut updated_data = token_data.clone();
4713        updated_data.price_usd = 1.5;
4714        state.update(&updated_data);
4715
4716        let (synthetic2, real2) = state.data_stats();
4717        assert!(synthetic2 > 0);
4718        assert_eq!(real2, 1);
4719        assert_eq!(state.real_data_count, 1);
4720
4721        // The last point should be real
4722        assert!(
4723            state
4724                .price_history
4725                .back()
4726                .map(|p| p.is_real)
4727                .unwrap_or(false)
4728        );
4729    }
4730
4731    #[test]
4732    fn test_memory_usage() {
4733        let token_data = create_test_token_data();
4734        let state = MonitorState::new(&token_data, "ethereum");
4735
4736        let mem = state.memory_usage();
4737        // DataPoint is 24 bytes, should have some data points
4738        assert!(mem > 0);
4739
4740        // Each DataPoint is 24 bytes (f64 + f64 + bool + padding)
4741        let expected_point_size = std::mem::size_of::<DataPoint>();
4742        assert_eq!(expected_point_size, 24);
4743    }
4744
4745    #[test]
4746    fn test_get_data_for_period_returns_flags() {
4747        let token_data = create_test_token_data();
4748        let mut state = MonitorState::new(&token_data, "ethereum");
4749
4750        // Get initial data (may contain cached real data or synthetic)
4751        let (data, is_real) = state.get_price_data_for_period();
4752        assert_eq!(data.len(), is_real.len());
4753
4754        // Add real data point
4755        state.update(&token_data);
4756
4757        let (_data2, is_real2) = state.get_price_data_for_period();
4758        // Should have at least one real point now
4759        assert!(is_real2.iter().any(|r| *r));
4760    }
4761
4762    #[test]
4763    fn test_cache_path_generation() {
4764        let path =
4765            MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
4766        assert!(path.to_string_lossy().contains("bcc_monitor_"));
4767        assert!(path.to_string_lossy().contains("ethereum"));
4768        // Should be in temp directory
4769        let temp_dir = std::env::temp_dir();
4770        assert!(path.starts_with(temp_dir));
4771    }
4772
4773    #[test]
4774    fn test_cache_save_and_load() {
4775        let token_data = create_test_token_data();
4776        let mut state = MonitorState::new(&token_data, "test_chain");
4777
4778        // Add some real data
4779        state.update(&token_data);
4780        state.update(&token_data);
4781
4782        // Save cache
4783        state.save_cache();
4784
4785        // Verify cache file exists
4786        let path = MonitorState::cache_path(&state.token_address, &state.chain);
4787        assert!(path.exists(), "Cache file should exist after save");
4788
4789        // Load cache
4790        let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
4791        assert!(loaded.is_some(), "Should be able to load saved cache");
4792
4793        let cached = loaded.unwrap();
4794        assert_eq!(cached.token_address, state.token_address);
4795        assert_eq!(cached.chain, state.chain);
4796        assert!(!cached.price_history.is_empty());
4797
4798        // Cleanup
4799        let _ = std::fs::remove_file(path);
4800    }
4801
4802    // ========================================================================
4803    // Price formatting tests
4804    // ========================================================================
4805
4806    #[test]
4807    fn test_format_price_usd_high() {
4808        let formatted = format_price_usd(2500.50);
4809        assert!(formatted.starts_with("$2500.50"));
4810    }
4811
4812    #[test]
4813    fn test_format_price_usd_stablecoin() {
4814        let formatted = format_price_usd(1.0001);
4815        assert!(formatted.contains("1.000100"));
4816        assert!(is_stablecoin_price(1.0001));
4817    }
4818
4819    #[test]
4820    fn test_format_price_usd_medium() {
4821        let formatted = format_price_usd(5.1234);
4822        assert!(formatted.starts_with("$5.1234"));
4823    }
4824
4825    #[test]
4826    fn test_format_price_usd_small() {
4827        let formatted = format_price_usd(0.05);
4828        assert!(formatted.starts_with("$0.0500"));
4829    }
4830
4831    #[test]
4832    fn test_format_price_usd_micro() {
4833        let formatted = format_price_usd(0.001);
4834        assert!(formatted.starts_with("$0.0010"));
4835    }
4836
4837    #[test]
4838    fn test_format_price_usd_nano() {
4839        let formatted = format_price_usd(0.00001);
4840        assert!(formatted.contains("0.0000100"));
4841    }
4842
4843    #[test]
4844    fn test_is_stablecoin_price() {
4845        assert!(is_stablecoin_price(1.0));
4846        assert!(is_stablecoin_price(0.999));
4847        assert!(is_stablecoin_price(1.001));
4848        assert!(is_stablecoin_price(0.95));
4849        assert!(is_stablecoin_price(1.05));
4850        assert!(!is_stablecoin_price(0.94));
4851        assert!(!is_stablecoin_price(1.06));
4852        assert!(!is_stablecoin_price(100.0));
4853    }
4854
4855    // ========================================================================
4856    // OHLC candle tests
4857    // ========================================================================
4858
4859    #[test]
4860    fn test_ohlc_candle_new() {
4861        let candle = OhlcCandle::new(1000.0, 50.0);
4862        assert_eq!(candle.open, 50.0);
4863        assert_eq!(candle.high, 50.0);
4864        assert_eq!(candle.low, 50.0);
4865        assert_eq!(candle.close, 50.0);
4866        assert!(candle.is_bullish);
4867    }
4868
4869    #[test]
4870    fn test_ohlc_candle_update() {
4871        let mut candle = OhlcCandle::new(1000.0, 50.0);
4872        candle.update(55.0);
4873        assert_eq!(candle.high, 55.0);
4874        assert_eq!(candle.close, 55.0);
4875        assert!(candle.is_bullish);
4876
4877        candle.update(45.0);
4878        assert_eq!(candle.low, 45.0);
4879        assert_eq!(candle.close, 45.0);
4880        assert!(!candle.is_bullish); // close < open
4881    }
4882
4883    #[test]
4884    fn test_get_ohlc_candles() {
4885        let token_data = create_test_token_data();
4886        let mut state = MonitorState::new(&token_data, "ethereum");
4887        // Add several data points
4888        for i in 0..20 {
4889            let mut data = token_data.clone();
4890            data.price_usd = 1.0 + (i as f64 * 0.01);
4891            state.update(&data);
4892        }
4893        let candles = state.get_ohlc_candles();
4894        // Should have some candles
4895        assert!(!candles.is_empty());
4896    }
4897
4898    #[test]
4899    fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
4900        let token_data = create_test_token_data();
4901        let mut state = MonitorState::new(&token_data, "ethereum");
4902        // Populate exchange OHLC
4903        let exchange_candles = vec![
4904            OhlcCandle::new(1700000000.0, 100.0),
4905            OhlcCandle::new(1700003600.0, 101.0),
4906        ];
4907        state.exchange_ohlc = exchange_candles.clone();
4908        let candles = state.get_ohlc_candles();
4909        assert_eq!(candles.len(), 2);
4910        assert_eq!(candles[0].timestamp, 1700000000.0);
4911        assert_eq!(candles[0].open, 100.0);
4912        assert_eq!(candles[1].timestamp, 1700003600.0);
4913        assert_eq!(candles[1].open, 101.0);
4914    }
4915
4916    // ========================================================================
4917    // ChartMode tests
4918    // ========================================================================
4919
4920    #[test]
4921    fn test_chart_mode_cycle() {
4922        let mode = ChartMode::Line;
4923        assert_eq!(mode.next(), ChartMode::Candlestick);
4924        assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
4925        assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
4926    }
4927
4928    #[test]
4929    fn test_chart_mode_label() {
4930        assert_eq!(ChartMode::Line.label(), "Line");
4931        assert_eq!(ChartMode::Candlestick.label(), "Candle");
4932        assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
4933    }
4934
4935    // ========================================================================
4936    // TUI rendering tests (headless TestBackend)
4937    // ========================================================================
4938
4939    use ratatui::Terminal;
4940    use ratatui::backend::TestBackend;
4941
4942    fn create_test_terminal() -> Terminal<TestBackend> {
4943        let backend = TestBackend::new(120, 40);
4944        Terminal::new(backend).unwrap()
4945    }
4946
4947    fn create_populated_state() -> MonitorState {
4948        let token_data = create_test_token_data();
4949        let mut state = MonitorState::new(&token_data, "ethereum");
4950        // Add real data points so charts have content
4951        for i in 0..30 {
4952            let mut data = token_data.clone();
4953            data.price_usd = 1.0 + (i as f64 * 0.01);
4954            data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
4955            state.update(&data);
4956        }
4957        state
4958    }
4959
4960    #[test]
4961    fn test_render_header_no_panic() {
4962        let mut terminal = create_test_terminal();
4963        let state = create_populated_state();
4964        terminal
4965            .draw(|f| render_header(f, f.area(), &state))
4966            .unwrap();
4967    }
4968
4969    #[test]
4970    fn test_render_price_chart_no_panic() {
4971        let mut terminal = create_test_terminal();
4972        let state = create_populated_state();
4973        terminal
4974            .draw(|f| render_price_chart(f, f.area(), &state))
4975            .unwrap();
4976    }
4977
4978    #[test]
4979    fn test_render_price_chart_line_mode() {
4980        let mut terminal = create_test_terminal();
4981        let mut state = create_populated_state();
4982        state.chart_mode = ChartMode::Line;
4983        terminal
4984            .draw(|f| render_price_chart(f, f.area(), &state))
4985            .unwrap();
4986    }
4987
4988    #[test]
4989    fn test_render_candlestick_chart_no_panic() {
4990        let mut terminal = create_test_terminal();
4991        let state = create_populated_state();
4992        terminal
4993            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4994            .unwrap();
4995    }
4996
4997    #[test]
4998    fn test_render_candlestick_chart_empty() {
4999        let mut terminal = create_test_terminal();
5000        let token_data = create_test_token_data();
5001        let state = MonitorState::new(&token_data, "ethereum");
5002        terminal
5003            .draw(|f| render_candlestick_chart(f, f.area(), &state))
5004            .unwrap();
5005    }
5006
5007    #[test]
5008    fn test_render_volume_chart_no_panic() {
5009        let mut terminal = create_test_terminal();
5010        let state = create_populated_state();
5011        terminal
5012            .draw(|f| render_volume_chart(f, f.area(), &state))
5013            .unwrap();
5014    }
5015
5016    #[test]
5017    fn test_render_volume_chart_empty() {
5018        let mut terminal = create_test_terminal();
5019        let token_data = create_test_token_data();
5020        let state = MonitorState::new(&token_data, "ethereum");
5021        terminal
5022            .draw(|f| render_volume_chart(f, f.area(), &state))
5023            .unwrap();
5024    }
5025
5026    #[test]
5027    fn test_render_buy_sell_gauge_no_panic() {
5028        let mut terminal = create_test_terminal();
5029        let mut state = create_populated_state();
5030        terminal
5031            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5032            .unwrap();
5033    }
5034
5035    #[test]
5036    fn test_render_buy_sell_gauge_balanced() {
5037        let mut terminal = create_test_terminal();
5038        let mut token_data = create_test_token_data();
5039        token_data.total_buys_24h = 100;
5040        token_data.total_sells_24h = 100;
5041        let mut state = MonitorState::new(&token_data, "ethereum");
5042        terminal
5043            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5044            .unwrap();
5045    }
5046
5047    #[test]
5048    fn test_render_metrics_panel_no_panic() {
5049        let mut terminal = create_test_terminal();
5050        let state = create_populated_state();
5051        terminal
5052            .draw(|f| render_metrics_panel(f, f.area(), &state))
5053            .unwrap();
5054    }
5055
5056    #[test]
5057    fn test_render_metrics_panel_no_market_cap() {
5058        let mut terminal = create_test_terminal();
5059        let mut token_data = create_test_token_data();
5060        token_data.market_cap = None;
5061        token_data.fdv = None;
5062        let state = MonitorState::new(&token_data, "ethereum");
5063        terminal
5064            .draw(|f| render_metrics_panel(f, f.area(), &state))
5065            .unwrap();
5066    }
5067
5068    #[test]
5069    fn test_render_footer_no_panic() {
5070        let mut terminal = create_test_terminal();
5071        let state = create_populated_state();
5072        terminal
5073            .draw(|f| render_footer(f, f.area(), &state))
5074            .unwrap();
5075    }
5076
5077    #[test]
5078    fn test_render_footer_paused() {
5079        let mut terminal = create_test_terminal();
5080        let token_data = create_test_token_data();
5081        let mut state = MonitorState::new(&token_data, "ethereum");
5082        state.paused = true;
5083        terminal
5084            .draw(|f| render_footer(f, f.area(), &state))
5085            .unwrap();
5086    }
5087
5088    #[test]
5089    fn test_render_all_components() {
5090        // Exercise the full draw_ui layout path
5091        let mut terminal = create_test_terminal();
5092        let mut state = create_populated_state();
5093        terminal
5094            .draw(|f| {
5095                let area = f.area();
5096                let chunks = Layout::default()
5097                    .direction(Direction::Vertical)
5098                    .constraints([
5099                        Constraint::Length(3),
5100                        Constraint::Min(10),
5101                        Constraint::Length(5),
5102                        Constraint::Length(3),
5103                        Constraint::Length(3),
5104                    ])
5105                    .split(area);
5106                render_header(f, chunks[0], &state);
5107                render_price_chart(f, chunks[1], &state);
5108                render_volume_chart(f, chunks[2], &state);
5109                render_buy_sell_gauge(f, chunks[3], &mut state);
5110                render_footer(f, chunks[4], &state);
5111            })
5112            .unwrap();
5113    }
5114
5115    #[test]
5116    fn test_render_candlestick_mode() {
5117        let mut terminal = create_test_terminal();
5118        let mut state = create_populated_state();
5119        state.chart_mode = ChartMode::Candlestick;
5120        terminal
5121            .draw(|f| {
5122                let area = f.area();
5123                let chunks = Layout::default()
5124                    .direction(Direction::Vertical)
5125                    .constraints([Constraint::Length(3), Constraint::Min(10)])
5126                    .split(area);
5127                render_header(f, chunks[0], &state);
5128                render_candlestick_chart(f, chunks[1], &state);
5129            })
5130            .unwrap();
5131    }
5132
5133    #[test]
5134    fn test_render_with_different_time_periods() {
5135        let mut terminal = create_test_terminal();
5136        let mut state = create_populated_state();
5137
5138        for period in [
5139            TimePeriod::Min1,
5140            TimePeriod::Min5,
5141            TimePeriod::Min15,
5142            TimePeriod::Hour1,
5143            TimePeriod::Hour4,
5144            TimePeriod::Day1,
5145        ] {
5146            state.time_period = period;
5147            terminal
5148                .draw(|f| render_price_chart(f, f.area(), &state))
5149                .unwrap();
5150        }
5151    }
5152
5153    #[test]
5154    fn test_render_metrics_with_stablecoin() {
5155        let mut terminal = create_test_terminal();
5156        let mut token_data = create_test_token_data();
5157        token_data.price_usd = 0.999;
5158        token_data.symbol = "USDC".to_string();
5159        let state = MonitorState::new(&token_data, "ethereum");
5160        terminal
5161            .draw(|f| render_metrics_panel(f, f.area(), &state))
5162            .unwrap();
5163    }
5164
5165    #[test]
5166    fn test_render_header_with_negative_change() {
5167        let mut terminal = create_test_terminal();
5168        let mut token_data = create_test_token_data();
5169        token_data.price_change_24h = -15.5;
5170        token_data.price_change_1h = -2.3;
5171        let state = MonitorState::new(&token_data, "ethereum");
5172        terminal
5173            .draw(|f| render_header(f, f.area(), &state))
5174            .unwrap();
5175    }
5176
5177    // ========================================================================
5178    // MonitorState method tests
5179    // ========================================================================
5180
5181    #[test]
5182    fn test_toggle_chart_mode_roundtrip() {
5183        let token_data = create_test_token_data();
5184        let mut state = MonitorState::new(&token_data, "ethereum");
5185        assert_eq!(state.chart_mode, ChartMode::Line);
5186        state.toggle_chart_mode();
5187        assert_eq!(state.chart_mode, ChartMode::Candlestick);
5188        state.toggle_chart_mode();
5189        assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
5190        state.toggle_chart_mode();
5191        assert_eq!(state.chart_mode, ChartMode::Line);
5192    }
5193
5194    #[test]
5195    fn test_cycle_all_time_periods() {
5196        let token_data = create_test_token_data();
5197        let mut state = MonitorState::new(&token_data, "ethereum");
5198        assert_eq!(state.time_period, TimePeriod::Hour1);
5199        state.cycle_time_period();
5200        assert_eq!(state.time_period, TimePeriod::Hour4);
5201        state.cycle_time_period();
5202        assert_eq!(state.time_period, TimePeriod::Day1);
5203        state.cycle_time_period();
5204        assert_eq!(state.time_period, TimePeriod::Min1);
5205        state.cycle_time_period();
5206        assert_eq!(state.time_period, TimePeriod::Min5);
5207        state.cycle_time_period();
5208        assert_eq!(state.time_period, TimePeriod::Min15);
5209        state.cycle_time_period();
5210        assert_eq!(state.time_period, TimePeriod::Hour1);
5211    }
5212
5213    #[test]
5214    fn test_set_specific_time_period() {
5215        let token_data = create_test_token_data();
5216        let mut state = MonitorState::new(&token_data, "ethereum");
5217        state.set_time_period(TimePeriod::Day1);
5218        assert_eq!(state.time_period, TimePeriod::Day1);
5219    }
5220
5221    #[test]
5222    fn test_pause_resume_roundtrip() {
5223        let token_data = create_test_token_data();
5224        let mut state = MonitorState::new(&token_data, "ethereum");
5225        assert!(!state.paused);
5226        state.toggle_pause();
5227        assert!(state.paused);
5228        state.toggle_pause();
5229        assert!(!state.paused);
5230    }
5231
5232    #[test]
5233    fn test_force_refresh_unpauses() {
5234        let token_data = create_test_token_data();
5235        let mut state = MonitorState::new(&token_data, "ethereum");
5236        state.paused = true;
5237        state.force_refresh();
5238        assert!(!state.paused);
5239        assert!(state.should_refresh());
5240    }
5241
5242    #[test]
5243    fn test_refresh_rate_adjust() {
5244        let token_data = create_test_token_data();
5245        let mut state = MonitorState::new(&token_data, "ethereum");
5246        assert_eq!(state.refresh_rate_secs(), 5);
5247
5248        state.slower_refresh();
5249        assert_eq!(state.refresh_rate_secs(), 10);
5250
5251        state.faster_refresh();
5252        assert_eq!(state.refresh_rate_secs(), 5);
5253    }
5254
5255    #[test]
5256    fn test_faster_refresh_clamped_min() {
5257        let token_data = create_test_token_data();
5258        let mut state = MonitorState::new(&token_data, "ethereum");
5259        for _ in 0..10 {
5260            state.faster_refresh();
5261        }
5262        assert!(state.refresh_rate_secs() >= 1);
5263    }
5264
5265    #[test]
5266    fn test_slower_refresh_clamped_max() {
5267        let token_data = create_test_token_data();
5268        let mut state = MonitorState::new(&token_data, "ethereum");
5269        for _ in 0..20 {
5270            state.slower_refresh();
5271        }
5272        assert!(state.refresh_rate_secs() <= 60);
5273    }
5274
5275    #[test]
5276    fn test_buy_ratio_balanced() {
5277        let mut token_data = create_test_token_data();
5278        token_data.total_buys_24h = 100;
5279        token_data.total_sells_24h = 100;
5280        let state = MonitorState::new(&token_data, "ethereum");
5281        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5282    }
5283
5284    #[test]
5285    fn test_buy_ratio_no_trades() {
5286        let mut token_data = create_test_token_data();
5287        token_data.total_buys_24h = 0;
5288        token_data.total_sells_24h = 0;
5289        let state = MonitorState::new(&token_data, "ethereum");
5290        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5291    }
5292
5293    #[test]
5294    fn test_data_stats_initial() {
5295        let token_data = create_test_token_data();
5296        let state = MonitorState::new(&token_data, "ethereum");
5297        let (synthetic, real) = state.data_stats();
5298        assert!(synthetic > 0 || real == 0);
5299    }
5300
5301    #[test]
5302    fn test_memory_usage_nonzero() {
5303        let token_data = create_test_token_data();
5304        let state = MonitorState::new(&token_data, "ethereum");
5305        let usage = state.memory_usage();
5306        assert!(usage > 0);
5307    }
5308
5309    #[test]
5310    fn test_price_data_for_period() {
5311        let token_data = create_test_token_data();
5312        let state = MonitorState::new(&token_data, "ethereum");
5313        let (data, is_real) = state.get_price_data_for_period();
5314        assert_eq!(data.len(), is_real.len());
5315    }
5316
5317    #[test]
5318    fn test_volume_data_for_period() {
5319        let token_data = create_test_token_data();
5320        let state = MonitorState::new(&token_data, "ethereum");
5321        let (data, is_real) = state.get_volume_data_for_period();
5322        assert_eq!(data.len(), is_real.len());
5323    }
5324
5325    #[test]
5326    fn test_ohlc_candles_generation() {
5327        let token_data = create_test_token_data();
5328        let state = MonitorState::new(&token_data, "ethereum");
5329        let candles = state.get_ohlc_candles();
5330        for candle in &candles {
5331            assert!(candle.high >= candle.low);
5332        }
5333    }
5334
5335    #[test]
5336    fn test_state_update_with_new_data() {
5337        let token_data = create_test_token_data();
5338        let mut state = MonitorState::new(&token_data, "ethereum");
5339        let initial_count = state.real_data_count;
5340
5341        let mut updated_data = create_test_token_data();
5342        updated_data.price_usd = 2.0;
5343        updated_data.volume_24h = 2_000_000.0;
5344
5345        state.update(&updated_data);
5346        assert_eq!(state.current_price, 2.0);
5347        assert_eq!(state.real_data_count, initial_count + 1);
5348        assert!(state.error_message.is_none());
5349    }
5350
5351    #[test]
5352    fn test_cache_roundtrip_save_load() {
5353        let token_data = create_test_token_data();
5354        let state = MonitorState::new(&token_data, "ethereum");
5355
5356        state.save_cache();
5357
5358        let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
5359        assert!(cache_path.exists());
5360
5361        let cached = MonitorState::load_cache(&token_data.address, "ethereum");
5362        assert!(cached.is_some());
5363
5364        let _ = std::fs::remove_file(cache_path);
5365    }
5366
5367    #[test]
5368    fn test_should_refresh_when_paused() {
5369        let token_data = create_test_token_data();
5370        let mut state = MonitorState::new(&token_data, "ethereum");
5371        assert!(!state.should_refresh());
5372        state.paused = true;
5373        assert!(!state.should_refresh());
5374    }
5375
5376    #[test]
5377    fn test_ohlc_candle_lifecycle() {
5378        let mut candle = OhlcCandle::new(1700000000.0, 100.0);
5379        assert_eq!(candle.open, 100.0);
5380        assert!(candle.is_bullish);
5381        candle.update(110.0);
5382        assert_eq!(candle.high, 110.0);
5383        assert!(candle.is_bullish);
5384        candle.update(90.0);
5385        assert_eq!(candle.low, 90.0);
5386        assert!(!candle.is_bullish);
5387    }
5388
5389    #[test]
5390    fn test_time_period_display_impl() {
5391        assert_eq!(format!("{}", TimePeriod::Min1), "1m");
5392        assert_eq!(format!("{}", TimePeriod::Min15), "15m");
5393        assert_eq!(format!("{}", TimePeriod::Day1), "1d");
5394    }
5395
5396    #[test]
5397    fn test_log_messages_accumulate() {
5398        let token_data = create_test_token_data();
5399        let mut state = MonitorState::new(&token_data, "ethereum");
5400        // Trigger actions that log
5401        state.toggle_pause();
5402        state.toggle_pause();
5403        state.cycle_time_period();
5404        state.toggle_chart_mode();
5405        assert!(!state.log_messages.is_empty());
5406    }
5407
5408    #[test]
5409    fn test_ui_function_full_render() {
5410        // Test the main ui() function which orchestrates all rendering
5411        let mut terminal = create_test_terminal();
5412        let mut state = create_populated_state();
5413        terminal.draw(|f| ui(f, &mut state)).unwrap();
5414    }
5415
5416    #[test]
5417    fn test_ui_function_candlestick_mode() {
5418        let mut terminal = create_test_terminal();
5419        let mut state = create_populated_state();
5420        state.chart_mode = ChartMode::Candlestick;
5421        terminal.draw(|f| ui(f, &mut state)).unwrap();
5422    }
5423
5424    #[test]
5425    fn test_ui_function_with_error_message() {
5426        let mut terminal = create_test_terminal();
5427        let mut state = create_populated_state();
5428        state.error_message = Some("Test error".to_string());
5429        terminal.draw(|f| ui(f, &mut state)).unwrap();
5430    }
5431
5432    #[test]
5433    fn test_render_header_with_small_positive_change() {
5434        let mut terminal = create_test_terminal();
5435        let mut state = create_populated_state();
5436        state.price_change_24h = 0.3; // Between 0 and 0.5 -> △
5437        terminal
5438            .draw(|f| render_header(f, f.area(), &state))
5439            .unwrap();
5440    }
5441
5442    #[test]
5443    fn test_render_header_with_small_negative_change() {
5444        let mut terminal = create_test_terminal();
5445        let mut state = create_populated_state();
5446        state.price_change_24h = -0.3; // Between -0.5 and 0 -> ▽
5447        terminal
5448            .draw(|f| render_header(f, f.area(), &state))
5449            .unwrap();
5450    }
5451
5452    #[test]
5453    fn test_render_buy_sell_gauge_high_buy_ratio() {
5454        let mut terminal = create_test_terminal();
5455        let token_data = create_test_token_data();
5456        let mut state = MonitorState::new(&token_data, "ethereum");
5457        state.buys_24h = 100;
5458        state.sells_24h = 10;
5459        terminal
5460            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5461            .unwrap();
5462    }
5463
5464    #[test]
5465    fn test_render_buy_sell_gauge_zero_total() {
5466        let mut terminal = create_test_terminal();
5467        let token_data = create_test_token_data();
5468        let mut state = MonitorState::new(&token_data, "ethereum");
5469        state.buys_24h = 0;
5470        state.sells_24h = 0;
5471        terminal
5472            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5473            .unwrap();
5474    }
5475
5476    #[test]
5477    fn test_render_metrics_with_market_cap() {
5478        let mut terminal = create_test_terminal();
5479        let token_data = create_test_token_data();
5480        let mut state = MonitorState::new(&token_data, "ethereum");
5481        state.market_cap = Some(1_000_000_000.0);
5482        state.fdv = Some(2_000_000_000.0);
5483        terminal
5484            .draw(|f| render_metrics_panel(f, f.area(), &state))
5485            .unwrap();
5486    }
5487
5488    #[test]
5489    fn test_render_footer_with_error() {
5490        let mut terminal = create_test_terminal();
5491        let mut state = create_populated_state();
5492        state.error_message = Some("Connection failed".to_string());
5493        terminal
5494            .draw(|f| render_footer(f, f.area(), &state))
5495            .unwrap();
5496    }
5497
5498    #[test]
5499    fn test_format_price_usd_various() {
5500        // Test format_price_usd with various magnitudes
5501        assert!(!format_price_usd(0.0000001).is_empty());
5502        assert!(!format_price_usd(0.001).is_empty());
5503        assert!(!format_price_usd(1.0).is_empty());
5504        assert!(!format_price_usd(100.0).is_empty());
5505        assert!(!format_price_usd(10000.0).is_empty());
5506        assert!(!format_price_usd(1000000.0).is_empty());
5507    }
5508
5509    #[test]
5510    fn test_format_usd_various() {
5511        assert!(!crate::display::format_usd(0.0).is_empty());
5512        assert!(!crate::display::format_usd(999.0).is_empty());
5513        assert!(!crate::display::format_usd(1500.0).is_empty());
5514        assert!(!crate::display::format_usd(1_500_000.0).is_empty());
5515        assert!(!crate::display::format_usd(1_500_000_000.0).is_empty());
5516        assert!(!crate::display::format_usd(1_500_000_000_000.0).is_empty());
5517    }
5518
5519    #[test]
5520    fn test_format_number_various() {
5521        assert!(!format_number(0.0).is_empty());
5522        assert!(!format_number(999.0).is_empty());
5523        assert!(!format_number(1500.0).is_empty());
5524        assert!(!format_number(1_500_000.0).is_empty());
5525        assert!(!format_number(1_500_000_000.0).is_empty());
5526    }
5527
5528    #[test]
5529    fn test_render_with_min15_period() {
5530        let mut terminal = create_test_terminal();
5531        let mut state = create_populated_state();
5532        state.set_time_period(TimePeriod::Min15);
5533        terminal.draw(|f| ui(f, &mut state)).unwrap();
5534    }
5535
5536    #[test]
5537    fn test_render_with_hour6_period() {
5538        let mut terminal = create_test_terminal();
5539        let mut state = create_populated_state();
5540        state.set_time_period(TimePeriod::Hour4);
5541        terminal.draw(|f| ui(f, &mut state)).unwrap();
5542    }
5543
5544    #[test]
5545    fn test_ui_with_fresh_state_no_real_data() {
5546        let mut terminal = create_test_terminal();
5547        let token_data = create_test_token_data();
5548        let mut state = MonitorState::new(&token_data, "ethereum");
5549        // Fresh state with only synthetic data
5550        terminal.draw(|f| ui(f, &mut state)).unwrap();
5551    }
5552
5553    #[test]
5554    fn test_ui_with_paused_state() {
5555        let mut terminal = create_test_terminal();
5556        let mut state = create_populated_state();
5557        state.toggle_pause();
5558        terminal.draw(|f| ui(f, &mut state)).unwrap();
5559    }
5560
5561    #[test]
5562    fn test_render_all_with_different_time_periods_and_modes() {
5563        let mut terminal = create_test_terminal();
5564        let mut state = create_populated_state();
5565
5566        // Test all combinations of time period + chart mode
5567        for period in &[
5568            TimePeriod::Min1,
5569            TimePeriod::Min5,
5570            TimePeriod::Min15,
5571            TimePeriod::Hour1,
5572            TimePeriod::Hour4,
5573            TimePeriod::Day1,
5574        ] {
5575            for mode in &[
5576                ChartMode::Line,
5577                ChartMode::Candlestick,
5578                ChartMode::VolumeProfile,
5579            ] {
5580                state.set_time_period(*period);
5581                state.chart_mode = *mode;
5582                terminal.draw(|f| ui(f, &mut state)).unwrap();
5583            }
5584        }
5585    }
5586
5587    #[test]
5588    fn test_render_metrics_with_large_values() {
5589        let mut terminal = create_test_terminal();
5590        let mut state = create_populated_state();
5591        state.market_cap = Some(50_000_000_000.0); // 50B
5592        state.fdv = Some(100_000_000_000.0); // 100B
5593        state.volume_24h = 5_000_000_000.0; // 5B
5594        state.liquidity_usd = 500_000_000.0; // 500M
5595        terminal
5596            .draw(|f| render_metrics_panel(f, f.area(), &state))
5597            .unwrap();
5598    }
5599
5600    #[test]
5601    fn test_render_header_large_positive_change() {
5602        let mut terminal = create_test_terminal();
5603        let mut state = create_populated_state();
5604        state.price_change_24h = 50.0; // >0.5 -> ▲
5605        terminal
5606            .draw(|f| render_header(f, f.area(), &state))
5607            .unwrap();
5608    }
5609
5610    #[test]
5611    fn test_render_header_large_negative_change() {
5612        let mut terminal = create_test_terminal();
5613        let mut state = create_populated_state();
5614        state.price_change_24h = -50.0; // <-0.5 -> ▼
5615        terminal
5616            .draw(|f| render_header(f, f.area(), &state))
5617            .unwrap();
5618    }
5619
5620    #[test]
5621    fn test_render_price_chart_empty_data() {
5622        let mut terminal = create_test_terminal();
5623        let token_data = create_test_token_data();
5624        // Create state with no price history data
5625        let mut state = MonitorState::new(&token_data, "ethereum");
5626        state.price_history.clear();
5627        terminal
5628            .draw(|f| render_price_chart(f, f.area(), &state))
5629            .unwrap();
5630    }
5631
5632    #[test]
5633    fn test_render_price_chart_price_down() {
5634        let mut terminal = create_test_terminal();
5635        let mut state = create_populated_state();
5636        // Force price down scenario
5637        state.price_change_24h = -15.0;
5638        state.current_price = 0.5; // Below initial
5639        terminal
5640            .draw(|f| render_price_chart(f, f.area(), &state))
5641            .unwrap();
5642    }
5643
5644    #[test]
5645    fn test_render_price_chart_zero_first_price() {
5646        let mut terminal = create_test_terminal();
5647        let mut token_data = create_test_token_data();
5648        token_data.price_usd = 0.0;
5649        let state = MonitorState::new(&token_data, "ethereum");
5650        terminal
5651            .draw(|f| render_price_chart(f, f.area(), &state))
5652            .unwrap();
5653    }
5654
5655    #[test]
5656    fn test_render_metrics_panel_zero_5m_change() {
5657        let mut terminal = create_test_terminal();
5658        let mut state = create_populated_state();
5659        state.price_change_5m = 0.0; // Exactly zero
5660        terminal
5661            .draw(|f| render_metrics_panel(f, f.area(), &state))
5662            .unwrap();
5663    }
5664
5665    #[test]
5666    fn test_render_metrics_panel_positive_5m_change() {
5667        let mut terminal = create_test_terminal();
5668        let mut state = create_populated_state();
5669        state.price_change_5m = 5.0; // Positive
5670        terminal
5671            .draw(|f| render_metrics_panel(f, f.area(), &state))
5672            .unwrap();
5673    }
5674
5675    #[test]
5676    fn test_render_metrics_panel_negative_5m_change() {
5677        let mut terminal = create_test_terminal();
5678        let mut state = create_populated_state();
5679        state.price_change_5m = -3.0; // Negative
5680        terminal
5681            .draw(|f| render_metrics_panel(f, f.area(), &state))
5682            .unwrap();
5683    }
5684
5685    #[test]
5686    fn test_render_metrics_panel_negative_24h_change() {
5687        let mut terminal = create_test_terminal();
5688        let mut state = create_populated_state();
5689        state.price_change_24h = -10.0;
5690        terminal
5691            .draw(|f| render_metrics_panel(f, f.area(), &state))
5692            .unwrap();
5693    }
5694
5695    #[test]
5696    fn test_render_metrics_panel_old_last_change() {
5697        let mut terminal = create_test_terminal();
5698        let mut state = create_populated_state();
5699        // Set last_price_change_at to over an hour ago
5700        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; // 2h ago
5701        terminal
5702            .draw(|f| render_metrics_panel(f, f.area(), &state))
5703            .unwrap();
5704    }
5705
5706    #[test]
5707    fn test_render_metrics_panel_minutes_ago_change() {
5708        let mut terminal = create_test_terminal();
5709        let mut state = create_populated_state();
5710        // Set last_price_change_at to minutes ago
5711        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; // 5 min ago
5712        terminal
5713            .draw(|f| render_metrics_panel(f, f.area(), &state))
5714            .unwrap();
5715    }
5716
5717    #[test]
5718    fn test_render_candlestick_empty_fresh_state() {
5719        let mut terminal = create_test_terminal();
5720        let token_data = create_test_token_data();
5721        let mut state = MonitorState::new(&token_data, "ethereum");
5722        state.price_history.clear();
5723        state.chart_mode = ChartMode::Candlestick;
5724        terminal
5725            .draw(|f| render_candlestick_chart(f, f.area(), &state))
5726            .unwrap();
5727    }
5728
5729    #[test]
5730    fn test_render_candlestick_price_down() {
5731        let mut terminal = create_test_terminal();
5732        let token_data = create_test_token_data();
5733        let mut state = MonitorState::new(&token_data, "ethereum");
5734        // Add data going down
5735        for i in 0..20 {
5736            let mut data = token_data.clone();
5737            data.price_usd = 2.0 - (i as f64 * 0.05);
5738            state.update(&data);
5739        }
5740        state.chart_mode = ChartMode::Candlestick;
5741        terminal
5742            .draw(|f| render_candlestick_chart(f, f.area(), &state))
5743            .unwrap();
5744    }
5745
5746    #[test]
5747    fn test_render_volume_chart_with_many_points() {
5748        let mut terminal = create_test_terminal();
5749        let token_data = create_test_token_data();
5750        let mut state = MonitorState::new(&token_data, "ethereum");
5751        // Add lots of data points
5752        for i in 0..100 {
5753            let mut data = token_data.clone();
5754            data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
5755            data.price_usd = 1.0 + (i as f64 * 0.001);
5756            state.update(&data);
5757        }
5758        terminal
5759            .draw(|f| render_volume_chart(f, f.area(), &state))
5760            .unwrap();
5761    }
5762
5763    // ========================================================================
5764    // Key event handler tests
5765    // ========================================================================
5766
5767    fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5768        crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
5769    }
5770
5771    fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5772        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
5773    }
5774
5775    #[test]
5776    fn test_handle_key_quit_q() {
5777        let token_data = create_test_token_data();
5778        let mut state = MonitorState::new(&token_data, "ethereum");
5779        assert!(handle_key_event_on_state(
5780            make_key_event(KeyCode::Char('q')),
5781            &mut state
5782        ));
5783    }
5784
5785    #[test]
5786    fn test_handle_key_quit_esc() {
5787        let token_data = create_test_token_data();
5788        let mut state = MonitorState::new(&token_data, "ethereum");
5789        assert!(handle_key_event_on_state(
5790            make_key_event(KeyCode::Esc),
5791            &mut state
5792        ));
5793    }
5794
5795    #[test]
5796    fn test_handle_key_quit_ctrl_c() {
5797        let token_data = create_test_token_data();
5798        let mut state = MonitorState::new(&token_data, "ethereum");
5799        assert!(handle_key_event_on_state(
5800            make_ctrl_key_event(KeyCode::Char('c')),
5801            &mut state
5802        ));
5803    }
5804
5805    #[test]
5806    fn test_handle_key_refresh() {
5807        let token_data = create_test_token_data();
5808        let mut state = MonitorState::new(&token_data, "ethereum");
5809        state.refresh_rate = Duration::from_secs(60);
5810        // Set last_update in the past so should_refresh was false
5811        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
5812        assert!(!exit);
5813        // force_refresh sets last_update to epoch, so should_refresh() should be true
5814        assert!(state.should_refresh());
5815    }
5816
5817    #[test]
5818    fn test_handle_key_pause_toggle() {
5819        let token_data = create_test_token_data();
5820        let mut state = MonitorState::new(&token_data, "ethereum");
5821        assert!(!state.paused);
5822
5823        handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
5824        assert!(state.paused);
5825
5826        handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
5827        assert!(!state.paused);
5828    }
5829
5830    #[test]
5831    fn test_handle_key_slower_refresh() {
5832        let token_data = create_test_token_data();
5833        let mut state = MonitorState::new(&token_data, "ethereum");
5834        let initial = state.refresh_rate;
5835
5836        handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
5837        assert!(state.refresh_rate > initial);
5838
5839        state.refresh_rate = initial;
5840        handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
5841        assert!(state.refresh_rate > initial);
5842
5843        state.refresh_rate = initial;
5844        handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
5845        assert!(state.refresh_rate > initial);
5846    }
5847
5848    #[test]
5849    fn test_handle_key_faster_refresh() {
5850        let token_data = create_test_token_data();
5851        let mut state = MonitorState::new(&token_data, "ethereum");
5852        // First make it slower so there's room to go faster
5853        state.refresh_rate = Duration::from_secs(30);
5854        let initial = state.refresh_rate;
5855
5856        handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
5857        assert!(state.refresh_rate < initial);
5858
5859        state.refresh_rate = initial;
5860        handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
5861        assert!(state.refresh_rate < initial);
5862
5863        state.refresh_rate = initial;
5864        handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
5865        assert!(state.refresh_rate < initial);
5866    }
5867
5868    #[test]
5869    fn test_handle_key_time_periods() {
5870        let token_data = create_test_token_data();
5871        let mut state = MonitorState::new(&token_data, "ethereum");
5872
5873        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
5874        assert!(matches!(state.time_period, TimePeriod::Min1));
5875
5876        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
5877        assert!(matches!(state.time_period, TimePeriod::Min5));
5878
5879        handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
5880        assert!(matches!(state.time_period, TimePeriod::Min15));
5881
5882        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
5883        assert!(matches!(state.time_period, TimePeriod::Hour1));
5884
5885        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
5886        assert!(matches!(state.time_period, TimePeriod::Hour4));
5887
5888        handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
5889        assert!(matches!(state.time_period, TimePeriod::Day1));
5890    }
5891
5892    #[test]
5893    fn test_handle_key_cycle_time_period() {
5894        let token_data = create_test_token_data();
5895        let mut state = MonitorState::new(&token_data, "ethereum");
5896
5897        handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
5898        // Should cycle from default
5899        let first = state.time_period;
5900
5901        handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
5902        // Should have cycled again
5903        // Verify it cycled (no panic is the main check)
5904        let _ = state.time_period;
5905        let _ = first;
5906    }
5907
5908    #[test]
5909    fn test_handle_key_toggle_chart_mode() {
5910        let token_data = create_test_token_data();
5911        let mut state = MonitorState::new(&token_data, "ethereum");
5912        let initial_mode = state.chart_mode;
5913
5914        handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
5915        assert!(state.chart_mode != initial_mode);
5916    }
5917
5918    #[test]
5919    fn test_handle_key_unknown_no_op() {
5920        let token_data = create_test_token_data();
5921        let mut state = MonitorState::new(&token_data, "ethereum");
5922        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
5923        assert!(!exit);
5924    }
5925
5926    // ========================================================================
5927    // Cache save/load tests
5928    // ========================================================================
5929
5930    #[test]
5931    fn test_save_and_load_cache() {
5932        let token_data = create_test_token_data();
5933        let mut state = MonitorState::new(&token_data, "ethereum");
5934        state.price_history.push_back(DataPoint {
5935            timestamp: 1.0,
5936            value: 100.0,
5937            is_real: true,
5938        });
5939        state.price_history.push_back(DataPoint {
5940            timestamp: 2.0,
5941            value: 101.0,
5942            is_real: true,
5943        });
5944        state.volume_history.push_back(DataPoint {
5945            timestamp: 1.0,
5946            value: 5000.0,
5947            is_real: true,
5948        });
5949
5950        // save_cache uses dirs::cache_dir() which we can't redirect easily
5951        // but we can test the load_cache path with a real write
5952        state.save_cache();
5953        let cached = MonitorState::load_cache(&state.token_address, &state.chain);
5954        // Cache may or may not exist depending on system - just verify no panic
5955        if let Some(c) = cached {
5956            assert_eq!(
5957                c.token_address.to_lowercase(),
5958                state.token_address.to_lowercase()
5959            );
5960        }
5961    }
5962
5963    #[test]
5964    fn test_load_cache_nonexistent_token() {
5965        let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
5966        assert!(cached.is_none());
5967    }
5968
5969    // ========================================================================
5970    // New widget tests: BarChart (volume), Table+Sparkline (metrics), scroll
5971    // ========================================================================
5972
5973    #[test]
5974    fn test_render_volume_barchart_with_populated_data() {
5975        // Verify the BarChart-based volume chart renders without panic
5976        // when state has many volume data points across different time periods
5977        let mut terminal = create_test_terminal();
5978        let mut state = create_populated_state();
5979        for period in [
5980            TimePeriod::Min1,
5981            TimePeriod::Min5,
5982            TimePeriod::Min15,
5983            TimePeriod::Hour1,
5984            TimePeriod::Hour4,
5985            TimePeriod::Day1,
5986        ] {
5987            state.set_time_period(period);
5988            terminal
5989                .draw(|f| render_volume_chart(f, f.area(), &state))
5990                .unwrap();
5991        }
5992    }
5993
5994    #[test]
5995    fn test_render_volume_barchart_narrow_terminal() {
5996        // BarChart with very narrow width should still render without panic
5997        let backend = TestBackend::new(20, 10);
5998        let mut terminal = Terminal::new(backend).unwrap();
5999        let state = create_populated_state();
6000        terminal
6001            .draw(|f| render_volume_chart(f, f.area(), &state))
6002            .unwrap();
6003    }
6004
6005    #[test]
6006    fn test_render_metrics_table_sparkline_no_panic() {
6007        // Verify the Table+Sparkline metrics panel renders without panic
6008        let mut terminal = create_test_terminal();
6009        let state = create_populated_state();
6010        terminal
6011            .draw(|f| render_metrics_panel(f, f.area(), &state))
6012            .unwrap();
6013    }
6014
6015    #[test]
6016    fn test_render_metrics_table_sparkline_all_periods() {
6017        // Ensure metrics panel renders correctly for every time period
6018        let mut terminal = create_test_terminal();
6019        let mut state = create_populated_state();
6020        for period in [
6021            TimePeriod::Min1,
6022            TimePeriod::Min5,
6023            TimePeriod::Min15,
6024            TimePeriod::Hour1,
6025            TimePeriod::Hour4,
6026            TimePeriod::Day1,
6027        ] {
6028            state.set_time_period(period);
6029            terminal
6030                .draw(|f| render_metrics_panel(f, f.area(), &state))
6031                .unwrap();
6032        }
6033    }
6034
6035    #[test]
6036    fn test_render_metrics_sparkline_trend_direction() {
6037        // When 5m change is negative, sparkline should still render
6038        let mut terminal = create_test_terminal();
6039        let mut state = create_populated_state();
6040        state.price_change_5m = -3.5;
6041        terminal
6042            .draw(|f| render_metrics_panel(f, f.area(), &state))
6043            .unwrap();
6044
6045        // When 5m change is positive
6046        state.price_change_5m = 2.0;
6047        terminal
6048            .draw(|f| render_metrics_panel(f, f.area(), &state))
6049            .unwrap();
6050
6051        // When 5m change is zero
6052        state.price_change_5m = 0.0;
6053        terminal
6054            .draw(|f| render_metrics_panel(f, f.area(), &state))
6055            .unwrap();
6056    }
6057
6058    #[test]
6059    fn test_render_tabs_time_period() {
6060        // Verify the Tabs widget in the header renders for each period
6061        let mut terminal = create_test_terminal();
6062        let mut state = create_populated_state();
6063        for period in [
6064            TimePeriod::Min1,
6065            TimePeriod::Min5,
6066            TimePeriod::Min15,
6067            TimePeriod::Hour1,
6068            TimePeriod::Hour4,
6069            TimePeriod::Day1,
6070        ] {
6071            state.set_time_period(period);
6072            terminal
6073                .draw(|f| render_header(f, f.area(), &state))
6074                .unwrap();
6075        }
6076    }
6077
6078    #[test]
6079    fn test_time_period_index() {
6080        assert_eq!(TimePeriod::Min1.index(), 0);
6081        assert_eq!(TimePeriod::Min5.index(), 1);
6082        assert_eq!(TimePeriod::Min15.index(), 2);
6083        assert_eq!(TimePeriod::Hour1.index(), 3);
6084        assert_eq!(TimePeriod::Hour4.index(), 4);
6085        assert_eq!(TimePeriod::Day1.index(), 5);
6086    }
6087
6088    #[test]
6089    fn test_scroll_log_down_from_start() {
6090        let token_data = create_test_token_data();
6091        let mut state = MonitorState::new(&token_data, "ethereum");
6092        state.log_messages.push_back("msg 1".to_string());
6093        state.log_messages.push_back("msg 2".to_string());
6094        state.log_messages.push_back("msg 3".to_string());
6095
6096        // Initially no selection
6097        assert_eq!(state.log_list_state.selected(), None);
6098
6099        // First scroll down selects item 0
6100        state.scroll_log_down();
6101        assert_eq!(state.log_list_state.selected(), Some(0));
6102
6103        // Second scroll moves to item 1
6104        state.scroll_log_down();
6105        assert_eq!(state.log_list_state.selected(), Some(1));
6106
6107        // Third scroll moves to item 2
6108        state.scroll_log_down();
6109        assert_eq!(state.log_list_state.selected(), Some(2));
6110
6111        // Fourth scroll stays at last item (bounds check)
6112        state.scroll_log_down();
6113        assert_eq!(state.log_list_state.selected(), Some(2));
6114    }
6115
6116    #[test]
6117    fn test_scroll_log_up_from_start() {
6118        let token_data = create_test_token_data();
6119        let mut state = MonitorState::new(&token_data, "ethereum");
6120        state.log_messages.push_back("msg 1".to_string());
6121        state.log_messages.push_back("msg 2".to_string());
6122        state.log_messages.push_back("msg 3".to_string());
6123
6124        // Scroll up from no selection goes to 0
6125        state.scroll_log_up();
6126        assert_eq!(state.log_list_state.selected(), Some(0));
6127
6128        // Can't go below 0
6129        state.scroll_log_up();
6130        assert_eq!(state.log_list_state.selected(), Some(0));
6131    }
6132
6133    #[test]
6134    fn test_scroll_log_up_down_roundtrip() {
6135        let token_data = create_test_token_data();
6136        let mut state = MonitorState::new(&token_data, "ethereum");
6137        for i in 0..10 {
6138            state.log_messages.push_back(format!("msg {}", i));
6139        }
6140
6141        // Scroll down 5 times
6142        for _ in 0..5 {
6143            state.scroll_log_down();
6144        }
6145        assert_eq!(state.log_list_state.selected(), Some(4));
6146
6147        // Scroll up 3 times
6148        for _ in 0..3 {
6149            state.scroll_log_up();
6150        }
6151        assert_eq!(state.log_list_state.selected(), Some(1));
6152    }
6153
6154    #[test]
6155    fn test_scroll_log_empty_no_panic() {
6156        let token_data = create_test_token_data();
6157        let mut state = MonitorState::new(&token_data, "ethereum");
6158        // With no log messages, scrolling should not panic
6159        state.scroll_log_down();
6160        state.scroll_log_up();
6161        assert!(
6162            state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
6163        );
6164    }
6165
6166    #[test]
6167    fn test_render_scrollable_activity_log() {
6168        // Ensure the stateful activity log renders without panic
6169        let mut terminal = create_test_terminal();
6170        let mut state = create_populated_state();
6171        for i in 0..20 {
6172            state
6173                .log_messages
6174                .push_back(format!("Activity event #{}", i));
6175        }
6176        // Scroll down a few items
6177        state.scroll_log_down();
6178        state.scroll_log_down();
6179        state.scroll_log_down();
6180
6181        terminal
6182            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
6183            .unwrap();
6184    }
6185
6186    #[test]
6187    fn test_handle_key_scroll_log_j_k() {
6188        let token_data = create_test_token_data();
6189        let mut state = MonitorState::new(&token_data, "ethereum");
6190        state.log_messages.push_back("line 1".to_string());
6191        state.log_messages.push_back("line 2".to_string());
6192
6193        // j scrolls down
6194        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6195        assert_eq!(state.log_list_state.selected(), Some(0));
6196
6197        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6198        assert_eq!(state.log_list_state.selected(), Some(1));
6199
6200        // k scrolls up
6201        handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
6202        assert_eq!(state.log_list_state.selected(), Some(0));
6203    }
6204
6205    #[test]
6206    fn test_handle_key_scroll_log_arrow_keys() {
6207        let token_data = create_test_token_data();
6208        let mut state = MonitorState::new(&token_data, "ethereum");
6209        state.log_messages.push_back("line 1".to_string());
6210        state.log_messages.push_back("line 2".to_string());
6211        state.log_messages.push_back("line 3".to_string());
6212
6213        // Down arrow scrolls down
6214        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6215        assert_eq!(state.log_list_state.selected(), Some(0));
6216
6217        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6218        assert_eq!(state.log_list_state.selected(), Some(1));
6219
6220        // Up arrow scrolls up
6221        handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
6222        assert_eq!(state.log_list_state.selected(), Some(0));
6223    }
6224
6225    #[test]
6226    fn test_render_ui_with_scrolled_log() {
6227        // Full UI render with a scrolled activity log position
6228        let mut terminal = create_test_terminal();
6229        let mut state = create_populated_state();
6230        for i in 0..15 {
6231            state.log_messages.push_back(format!("Log entry {}", i));
6232        }
6233        state.scroll_log_down();
6234        state.scroll_log_down();
6235        state.scroll_log_down();
6236        state.scroll_log_down();
6237        state.scroll_log_down();
6238
6239        terminal.draw(|f| ui(f, &mut state)).unwrap();
6240    }
6241
6242    // ========================================================================
6243    // Token selection / resolve tests
6244    // ========================================================================
6245
6246    fn make_monitor_search_results() -> Vec<crate::chains::dex::TokenSearchResult> {
6247        vec![
6248            crate::chains::dex::TokenSearchResult {
6249                symbol: "USDC".to_string(),
6250                name: "USD Coin".to_string(),
6251                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
6252                chain: "ethereum".to_string(),
6253                price_usd: Some(1.0),
6254                volume_24h: 1_000_000.0,
6255                liquidity_usd: 500_000_000.0,
6256                market_cap: Some(30_000_000_000.0),
6257            },
6258            crate::chains::dex::TokenSearchResult {
6259                symbol: "USDC".to_string(),
6260                name: "Bridged USD Coin".to_string(),
6261                address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
6262                chain: "ethereum".to_string(),
6263                price_usd: Some(0.9998),
6264                volume_24h: 500_000.0,
6265                liquidity_usd: 100_000_000.0,
6266                market_cap: None,
6267            },
6268            crate::chains::dex::TokenSearchResult {
6269                symbol: "USDC".to_string(),
6270                name: "A Very Long Token Name That Exceeds The Limit".to_string(),
6271                address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
6272                chain: "ethereum".to_string(),
6273                price_usd: None,
6274                volume_24h: 0.0,
6275                liquidity_usd: 50_000.0,
6276                market_cap: None,
6277            },
6278        ]
6279    }
6280
6281    #[test]
6282    fn test_abbreviate_address_long() {
6283        let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
6284        let abbr = abbreviate_address(addr);
6285        assert_eq!(abbr, "0xA0b869...06eB48");
6286        assert!(abbr.contains("..."));
6287    }
6288
6289    #[test]
6290    fn test_abbreviate_address_short() {
6291        let addr = "0x1234abcd";
6292        let abbr = abbreviate_address(addr);
6293        // Short addresses are not abbreviated
6294        assert_eq!(abbr, "0x1234abcd");
6295    }
6296
6297    #[test]
6298    fn test_select_token_impl_first() {
6299        let results = make_monitor_search_results();
6300        let input = b"1\n";
6301        let mut reader = std::io::Cursor::new(&input[..]);
6302        let mut writer = Vec::new();
6303
6304        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6305        assert_eq!(selected.name, "USD Coin");
6306        assert_eq!(
6307            selected.address,
6308            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
6309        );
6310
6311        let output = String::from_utf8(writer).unwrap();
6312        assert!(output.contains("Found 3 tokens"));
6313        assert!(output.contains("USDC"));
6314        assert!(output.contains("0xA0b869...06eB48"));
6315        assert!(output.contains("Selected:"));
6316    }
6317
6318    #[test]
6319    fn test_select_token_impl_second() {
6320        let results = make_monitor_search_results();
6321        let input = b"2\n";
6322        let mut reader = std::io::Cursor::new(&input[..]);
6323        let mut writer = Vec::new();
6324
6325        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6326        assert_eq!(selected.name, "Bridged USD Coin");
6327        assert_eq!(
6328            selected.address,
6329            "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
6330        );
6331    }
6332
6333    #[test]
6334    fn test_select_token_impl_shows_address_column() {
6335        let results = make_monitor_search_results();
6336        let input = b"1\n";
6337        let mut reader = std::io::Cursor::new(&input[..]);
6338        let mut writer = Vec::new();
6339
6340        select_token_impl(&results, &mut reader, &mut writer).unwrap();
6341        let output = String::from_utf8(writer).unwrap();
6342
6343        // Table header should include Address column
6344        assert!(output.contains("Address"));
6345        // All three abbreviated addresses should appear
6346        assert!(output.contains("0xA0b869...06eB48"));
6347        assert!(output.contains("0x2791Bc...a84174"));
6348        assert!(output.contains("0x123456...345678"));
6349    }
6350
6351    #[test]
6352    fn test_select_token_impl_truncates_long_name() {
6353        let results = make_monitor_search_results();
6354        let input = b"3\n";
6355        let mut reader = std::io::Cursor::new(&input[..]);
6356        let mut writer = Vec::new();
6357
6358        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6359        assert_eq!(
6360            selected.address,
6361            "0x1234567890abcdef1234567890abcdef12345678"
6362        );
6363
6364        let output = String::from_utf8(writer).unwrap();
6365        assert!(output.contains("A Very Long Token..."));
6366    }
6367
6368    #[test]
6369    fn test_select_token_impl_invalid_input() {
6370        let results = make_monitor_search_results();
6371        let input = b"xyz\n";
6372        let mut reader = std::io::Cursor::new(&input[..]);
6373        let mut writer = Vec::new();
6374
6375        let result = select_token_impl(&results, &mut reader, &mut writer);
6376        assert!(result.is_err());
6377        assert!(
6378            result
6379                .unwrap_err()
6380                .to_string()
6381                .contains("Invalid selection")
6382        );
6383    }
6384
6385    #[test]
6386    fn test_select_token_impl_out_of_range_zero() {
6387        let results = make_monitor_search_results();
6388        let input = b"0\n";
6389        let mut reader = std::io::Cursor::new(&input[..]);
6390        let mut writer = Vec::new();
6391
6392        let result = select_token_impl(&results, &mut reader, &mut writer);
6393        assert!(result.is_err());
6394        assert!(
6395            result
6396                .unwrap_err()
6397                .to_string()
6398                .contains("Selection must be between")
6399        );
6400    }
6401
6402    #[test]
6403    fn test_select_token_impl_out_of_range_high() {
6404        let results = make_monitor_search_results();
6405        let input = b"99\n";
6406        let mut reader = std::io::Cursor::new(&input[..]);
6407        let mut writer = Vec::new();
6408
6409        let result = select_token_impl(&results, &mut reader, &mut writer);
6410        assert!(result.is_err());
6411    }
6412
6413    #[test]
6414    fn test_format_monitor_number() {
6415        assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
6416        assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
6417        assert_eq!(format_monitor_number(75_000.0), "$75.00K");
6418        assert_eq!(format_monitor_number(42.5), "$42.50");
6419    }
6420
6421    // ============================
6422    // Phase 4: Layout system tests
6423    // ============================
6424
6425    #[test]
6426    fn test_monitor_config_defaults() {
6427        let config = MonitorConfig::default();
6428        assert_eq!(config.layout, LayoutPreset::Dashboard);
6429        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6430        assert!(config.widgets.price_chart);
6431        assert!(config.widgets.volume_chart);
6432        assert!(config.widgets.buy_sell_pressure);
6433        assert!(config.widgets.metrics_panel);
6434        assert!(config.widgets.activity_log);
6435    }
6436
6437    #[test]
6438    fn test_layout_preset_next_cycles() {
6439        assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
6440        assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
6441        assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
6442        assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
6443        assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
6444    }
6445
6446    #[test]
6447    fn test_layout_preset_prev_cycles() {
6448        assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
6449        assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
6450        assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
6451        assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
6452        assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
6453    }
6454
6455    #[test]
6456    fn test_layout_preset_full_cycle() {
6457        let start = LayoutPreset::Dashboard;
6458        let mut preset = start;
6459        for _ in 0..5 {
6460            preset = preset.next();
6461        }
6462        assert_eq!(preset, start);
6463    }
6464
6465    #[test]
6466    fn test_layout_preset_labels() {
6467        assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
6468        assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
6469        assert_eq!(LayoutPreset::Feed.label(), "Feed");
6470        assert_eq!(LayoutPreset::Compact.label(), "Compact");
6471        assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
6472    }
6473
6474    #[test]
6475    fn test_widget_visibility_default_all_visible() {
6476        let vis = WidgetVisibility::default();
6477        assert_eq!(vis.visible_count(), 5);
6478    }
6479
6480    #[test]
6481    fn test_widget_visibility_toggle_by_index() {
6482        let mut vis = WidgetVisibility::default();
6483        vis.toggle_by_index(1);
6484        assert!(!vis.price_chart);
6485        assert_eq!(vis.visible_count(), 4);
6486
6487        vis.toggle_by_index(2);
6488        assert!(!vis.volume_chart);
6489        assert_eq!(vis.visible_count(), 3);
6490
6491        vis.toggle_by_index(3);
6492        assert!(!vis.buy_sell_pressure);
6493        assert_eq!(vis.visible_count(), 2);
6494
6495        vis.toggle_by_index(4);
6496        assert!(!vis.metrics_panel);
6497        assert_eq!(vis.visible_count(), 1);
6498
6499        vis.toggle_by_index(5);
6500        assert!(!vis.activity_log);
6501        assert_eq!(vis.visible_count(), 0);
6502
6503        // Toggle back
6504        vis.toggle_by_index(1);
6505        assert!(vis.price_chart);
6506        assert_eq!(vis.visible_count(), 1);
6507    }
6508
6509    #[test]
6510    fn test_widget_visibility_toggle_invalid_index() {
6511        let mut vis = WidgetVisibility::default();
6512        vis.toggle_by_index(0);
6513        vis.toggle_by_index(6);
6514        vis.toggle_by_index(100);
6515        assert_eq!(vis.visible_count(), 5); // unchanged
6516    }
6517
6518    #[test]
6519    fn test_auto_select_layout_small_terminal() {
6520        let size = Rect::new(0, 0, 60, 20);
6521        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6522    }
6523
6524    #[test]
6525    fn test_auto_select_layout_narrow_terminal() {
6526        let size = Rect::new(0, 0, 100, 40);
6527        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6528    }
6529
6530    #[test]
6531    fn test_auto_select_layout_short_terminal() {
6532        let size = Rect::new(0, 0, 140, 28);
6533        assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
6534    }
6535
6536    #[test]
6537    fn test_auto_select_layout_large_terminal() {
6538        let size = Rect::new(0, 0, 160, 50);
6539        assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
6540    }
6541
6542    #[test]
6543    fn test_auto_select_layout_edge_80x24() {
6544        // Exactly at the threshold: width>=80 and height>=24, but width<120
6545        let size = Rect::new(0, 0, 80, 24);
6546        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6547    }
6548
6549    #[test]
6550    fn test_auto_select_layout_edge_79() {
6551        let size = Rect::new(0, 0, 79, 50);
6552        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6553    }
6554
6555    #[test]
6556    fn test_auto_select_layout_edge_23_height() {
6557        let size = Rect::new(0, 0, 160, 23);
6558        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6559    }
6560
6561    #[test]
6562    fn test_layout_dashboard_all_visible() {
6563        let area = Rect::new(0, 0, 120, 40);
6564        let vis = WidgetVisibility::default();
6565        let areas = layout_dashboard(area, &vis);
6566        assert!(areas.price_chart.is_some());
6567        assert!(areas.volume_chart.is_some());
6568        assert!(areas.buy_sell_gauge.is_some());
6569        assert!(areas.metrics_panel.is_some());
6570        assert!(areas.activity_feed.is_some());
6571    }
6572
6573    #[test]
6574    fn test_layout_dashboard_hidden_widget() {
6575        let area = Rect::new(0, 0, 120, 40);
6576        let vis = WidgetVisibility {
6577            price_chart: false,
6578            ..WidgetVisibility::default()
6579        };
6580        let areas = layout_dashboard(area, &vis);
6581        assert!(areas.price_chart.is_none());
6582        assert!(areas.volume_chart.is_some());
6583    }
6584
6585    #[test]
6586    fn test_layout_chart_focus_minimal_overlay() {
6587        let area = Rect::new(0, 0, 120, 40);
6588        let vis = WidgetVisibility::default();
6589        let areas = layout_chart_focus(area, &vis);
6590        assert!(areas.price_chart.is_some());
6591        assert!(areas.volume_chart.is_none()); // Hidden in chart-focus
6592        assert!(areas.buy_sell_gauge.is_none()); // Hidden in chart-focus
6593        assert!(areas.metrics_panel.is_some()); // Minimal stats overlay
6594        assert!(areas.activity_feed.is_none()); // Hidden in chart-focus
6595    }
6596
6597    #[test]
6598    fn test_layout_feed_activity_priority() {
6599        let area = Rect::new(0, 0, 120, 40);
6600        let vis = WidgetVisibility::default();
6601        let areas = layout_feed(area, &vis);
6602        assert!(areas.price_chart.is_none()); // Hidden in feed
6603        assert!(areas.volume_chart.is_none()); // Hidden in feed
6604        assert!(areas.buy_sell_gauge.is_some()); // Top row
6605        assert!(areas.metrics_panel.is_some()); // Top row
6606        assert!(areas.activity_feed.is_some()); // Dominates bottom 75%
6607    }
6608
6609    #[test]
6610    fn test_layout_compact_metrics_only() {
6611        let area = Rect::new(0, 0, 60, 20);
6612        let vis = WidgetVisibility::default();
6613        let areas = layout_compact(area, &vis);
6614        assert!(areas.price_chart.is_none()); // Hidden in compact
6615        assert!(areas.volume_chart.is_none()); // Hidden in compact
6616        assert!(areas.buy_sell_gauge.is_none()); // Hidden in compact
6617        assert!(areas.metrics_panel.is_some()); // Full area
6618        assert!(areas.activity_feed.is_none()); // Hidden in compact
6619    }
6620
6621    #[test]
6622    fn test_layout_exchange_has_order_book_and_market_info() {
6623        let area = Rect::new(0, 0, 160, 50);
6624        let vis = WidgetVisibility::default();
6625        let areas = layout_exchange(area, &vis);
6626        assert!(areas.order_book.is_some());
6627        assert!(areas.market_info.is_some());
6628        assert!(areas.price_chart.is_some());
6629        assert!(areas.buy_sell_gauge.is_some());
6630        assert!(areas.volume_chart.is_none()); // Not in exchange layout
6631        assert!(areas.metrics_panel.is_none()); // Not in exchange layout
6632        assert!(areas.activity_feed.is_none()); // Not in exchange layout
6633    }
6634
6635    #[test]
6636    fn test_ui_render_all_layouts_no_panic() {
6637        let presets = [
6638            LayoutPreset::Dashboard,
6639            LayoutPreset::ChartFocus,
6640            LayoutPreset::Feed,
6641            LayoutPreset::Compact,
6642            LayoutPreset::Exchange,
6643        ];
6644        for preset in &presets {
6645            let mut terminal = create_test_terminal();
6646            let mut state = create_populated_state();
6647            state.layout = *preset;
6648            state.auto_layout = false; // Don't override during render
6649            terminal.draw(|f| ui(f, &mut state)).unwrap();
6650        }
6651    }
6652
6653    #[test]
6654    fn test_ui_render_compact_small_terminal() {
6655        let backend = TestBackend::new(60, 20);
6656        let mut terminal = Terminal::new(backend).unwrap();
6657        let mut state = create_populated_state();
6658        state.layout = LayoutPreset::Compact;
6659        state.auto_layout = false;
6660        terminal.draw(|f| ui(f, &mut state)).unwrap();
6661    }
6662
6663    #[test]
6664    fn test_ui_auto_layout_selects_compact_for_small() {
6665        let backend = TestBackend::new(60, 20);
6666        let mut terminal = Terminal::new(backend).unwrap();
6667        let mut state = create_populated_state();
6668        state.layout = LayoutPreset::Dashboard;
6669        state.auto_layout = true;
6670        terminal.draw(|f| ui(f, &mut state)).unwrap();
6671        assert_eq!(state.layout, LayoutPreset::Compact);
6672    }
6673
6674    #[test]
6675    fn test_ui_auto_layout_disabled_keeps_preset() {
6676        let backend = TestBackend::new(60, 20);
6677        let mut terminal = Terminal::new(backend).unwrap();
6678        let mut state = create_populated_state();
6679        state.layout = LayoutPreset::Dashboard;
6680        state.auto_layout = false;
6681        terminal.draw(|f| ui(f, &mut state)).unwrap();
6682        assert_eq!(state.layout, LayoutPreset::Dashboard); // Not changed
6683    }
6684
6685    #[test]
6686    fn test_keybinding_l_cycles_layout_forward() {
6687        let mut state = create_populated_state();
6688        state.layout = LayoutPreset::Dashboard;
6689        state.auto_layout = true;
6690
6691        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6692        assert_eq!(state.layout, LayoutPreset::ChartFocus);
6693        assert!(!state.auto_layout); // Manual switch disables auto
6694
6695        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6696        assert_eq!(state.layout, LayoutPreset::Feed);
6697    }
6698
6699    #[test]
6700    fn test_keybinding_h_cycles_layout_backward() {
6701        let mut state = create_populated_state();
6702        state.layout = LayoutPreset::Dashboard;
6703        state.auto_layout = true;
6704
6705        handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
6706        assert_eq!(state.layout, LayoutPreset::Exchange);
6707        assert!(!state.auto_layout);
6708    }
6709
6710    #[test]
6711    fn test_keybinding_a_enables_auto_layout() {
6712        let mut state = create_populated_state();
6713        state.auto_layout = false;
6714
6715        handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
6716        assert!(state.auto_layout);
6717    }
6718
6719    #[test]
6720    fn test_keybinding_w_widget_toggle_mode() {
6721        let mut state = create_populated_state();
6722        assert!(!state.widget_toggle_mode);
6723
6724        // Press w to enter toggle mode
6725        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6726        assert!(state.widget_toggle_mode);
6727
6728        // Press 1 to toggle price_chart off
6729        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
6730        assert!(!state.widget_toggle_mode);
6731        assert!(!state.widgets.price_chart);
6732    }
6733
6734    #[test]
6735    fn test_keybinding_w_cancel_with_non_digit() {
6736        let mut state = create_populated_state();
6737
6738        // Enter widget toggle mode
6739        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6740        assert!(state.widget_toggle_mode);
6741
6742        // Press 'x' to cancel — should also process 'x' as a normal key (no-op)
6743        handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
6744        assert!(!state.widget_toggle_mode);
6745        assert!(state.widgets.price_chart); // unchanged
6746    }
6747
6748    #[test]
6749    fn test_keybinding_w_toggle_multiple_widgets() {
6750        let mut state = create_populated_state();
6751
6752        // Toggle widget 2 (volume_chart)
6753        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6754        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
6755        assert!(!state.widgets.volume_chart);
6756
6757        // Toggle widget 4 (metrics_panel)
6758        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6759        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
6760        assert!(!state.widgets.metrics_panel);
6761
6762        // Toggle widget 5 (activity_log)
6763        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6764        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
6765        assert!(!state.widgets.activity_log);
6766    }
6767
6768    #[test]
6769    fn test_monitor_config_serde_roundtrip() {
6770        let config = MonitorConfig {
6771            layout: LayoutPreset::ChartFocus,
6772            refresh_seconds: 5,
6773            widgets: WidgetVisibility {
6774                price_chart: true,
6775                volume_chart: false,
6776                buy_sell_pressure: true,
6777                metrics_panel: false,
6778                activity_log: true,
6779                holder_count: true,
6780                liquidity_depth: true,
6781            },
6782            scale: ScaleMode::Log,
6783            color_scheme: ColorScheme::BlueOrange,
6784            alerts: AlertConfig::default(),
6785            export: ExportConfig::default(),
6786            auto_pause_on_input: false,
6787            venue: None,
6788        };
6789
6790        let yaml = serde_yaml::to_string(&config).unwrap();
6791        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
6792        assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
6793        assert_eq!(parsed.refresh_seconds, 5);
6794        assert!(parsed.widgets.price_chart);
6795        assert!(!parsed.widgets.volume_chart);
6796        assert!(parsed.widgets.buy_sell_pressure);
6797        assert!(!parsed.widgets.metrics_panel);
6798        assert!(parsed.widgets.activity_log);
6799    }
6800
6801    #[test]
6802    fn test_monitor_config_serde_kebab_case() {
6803        let yaml = r#"
6804layout: chart-focus
6805refresh_seconds: 15
6806widgets:
6807  price_chart: true
6808  volume_chart: true
6809  buy_sell_pressure: false
6810  metrics_panel: true
6811  activity_log: false
6812"#;
6813        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6814        assert_eq!(config.layout, LayoutPreset::ChartFocus);
6815        assert_eq!(config.refresh_seconds, 15);
6816        assert!(!config.widgets.buy_sell_pressure);
6817        assert!(!config.widgets.activity_log);
6818    }
6819
6820    #[test]
6821    fn test_monitor_config_serde_default_missing_fields() {
6822        let yaml = "layout: feed\n";
6823        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6824        assert_eq!(config.layout, LayoutPreset::Feed);
6825        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6826        assert!(config.widgets.price_chart); // defaults
6827    }
6828
6829    #[test]
6830    fn test_state_apply_config() {
6831        let mut state = create_populated_state();
6832        let config = MonitorConfig {
6833            layout: LayoutPreset::Feed,
6834            refresh_seconds: 5,
6835            widgets: WidgetVisibility {
6836                price_chart: false,
6837                volume_chart: true,
6838                buy_sell_pressure: true,
6839                metrics_panel: false,
6840                activity_log: true,
6841                holder_count: true,
6842                liquidity_depth: true,
6843            },
6844            scale: ScaleMode::Log,
6845            color_scheme: ColorScheme::Monochrome,
6846            alerts: AlertConfig::default(),
6847            export: ExportConfig::default(),
6848            auto_pause_on_input: false,
6849            venue: None,
6850        };
6851        state.apply_config(&config);
6852        assert_eq!(state.layout, LayoutPreset::Feed);
6853        assert!(!state.widgets.price_chart);
6854        assert!(!state.widgets.metrics_panel);
6855        assert_eq!(state.refresh_rate, Duration::from_secs(5));
6856    }
6857
6858    #[test]
6859    fn test_layout_all_widgets_hidden_dashboard() {
6860        let area = Rect::new(0, 0, 120, 40);
6861        let vis = WidgetVisibility {
6862            price_chart: false,
6863            volume_chart: false,
6864            buy_sell_pressure: false,
6865            metrics_panel: false,
6866            activity_log: false,
6867            holder_count: false,
6868            liquidity_depth: false,
6869        };
6870        let areas = layout_dashboard(area, &vis);
6871        assert!(areas.price_chart.is_none());
6872        assert!(areas.volume_chart.is_none());
6873        assert!(areas.buy_sell_gauge.is_none());
6874        assert!(areas.metrics_panel.is_none());
6875        assert!(areas.activity_feed.is_none());
6876    }
6877
6878    #[test]
6879    fn test_ui_render_with_hidden_widgets() {
6880        let mut terminal = create_test_terminal();
6881        let mut state = create_populated_state();
6882        state.auto_layout = false;
6883        state.widgets.price_chart = false;
6884        state.widgets.volume_chart = false;
6885        terminal.draw(|f| ui(f, &mut state)).unwrap();
6886    }
6887
6888    #[test]
6889    fn test_ui_render_widget_toggle_mode_footer() {
6890        let mut terminal = create_test_terminal();
6891        let mut state = create_populated_state();
6892        state.auto_layout = false;
6893        state.widget_toggle_mode = true;
6894        terminal.draw(|f| ui(f, &mut state)).unwrap();
6895    }
6896
6897    #[test]
6898    fn test_monitor_state_new_has_layout_fields() {
6899        let token_data = create_test_token_data();
6900        let state = MonitorState::new(&token_data, "ethereum");
6901        assert_eq!(state.layout, LayoutPreset::Dashboard);
6902        assert!(state.auto_layout);
6903        assert!(!state.widget_toggle_mode);
6904        assert_eq!(state.widgets.visible_count(), 5);
6905    }
6906
6907    // ========================================================================
6908    // Phase 6: Data Source Integration tests
6909    // ========================================================================
6910
6911    #[test]
6912    fn test_monitor_state_has_holder_count_field() {
6913        let token_data = create_test_token_data();
6914        let state = MonitorState::new(&token_data, "ethereum");
6915        assert_eq!(state.holder_count, None);
6916        assert!(state.liquidity_pairs.is_empty());
6917        assert_eq!(state.holder_fetch_counter, 0);
6918    }
6919
6920    #[test]
6921    fn test_liquidity_pairs_extracted_on_update() {
6922        let mut token_data = create_test_token_data();
6923        token_data.pairs = vec![
6924            crate::chains::DexPair {
6925                dex_name: "Uniswap V3".to_string(),
6926                pair_address: "0xpair1".to_string(),
6927                base_token: "TEST".to_string(),
6928                quote_token: "WETH".to_string(),
6929                price_usd: 1.0,
6930                volume_24h: 500_000.0,
6931                liquidity_usd: 250_000.0,
6932                price_change_24h: 5.0,
6933                buys_24h: 50,
6934                sells_24h: 25,
6935                buys_6h: 10,
6936                sells_6h: 5,
6937                buys_1h: 3,
6938                sells_1h: 2,
6939                pair_created_at: None,
6940                url: None,
6941            },
6942            crate::chains::DexPair {
6943                dex_name: "SushiSwap".to_string(),
6944                pair_address: "0xpair2".to_string(),
6945                base_token: "TEST".to_string(),
6946                quote_token: "USDC".to_string(),
6947                price_usd: 1.0,
6948                volume_24h: 300_000.0,
6949                liquidity_usd: 150_000.0,
6950                price_change_24h: 3.0,
6951                buys_24h: 30,
6952                sells_24h: 15,
6953                buys_6h: 8,
6954                sells_6h: 4,
6955                buys_1h: 2,
6956                sells_1h: 1,
6957                pair_created_at: None,
6958                url: None,
6959            },
6960        ];
6961
6962        let mut state = MonitorState::new(&token_data, "ethereum");
6963        state.update(&token_data);
6964
6965        assert_eq!(state.liquidity_pairs.len(), 2);
6966        assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
6967        assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
6968    }
6969
6970    #[test]
6971    fn test_render_liquidity_depth_no_panic() {
6972        let mut terminal = create_test_terminal();
6973        let mut state = create_populated_state();
6974        state.liquidity_pairs = vec![
6975            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
6976            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
6977        ];
6978        terminal
6979            .draw(|f| render_liquidity_depth(f, f.area(), &state))
6980            .unwrap();
6981    }
6982
6983    #[test]
6984    fn test_render_liquidity_depth_empty() {
6985        let mut terminal = create_test_terminal();
6986        let state = create_populated_state();
6987        terminal
6988            .draw(|f| render_liquidity_depth(f, f.area(), &state))
6989            .unwrap();
6990    }
6991
6992    #[test]
6993    fn test_render_metrics_with_holder_count() {
6994        let mut terminal = create_test_terminal();
6995        let mut state = create_populated_state();
6996        state.holder_count = Some(42_000);
6997        terminal
6998            .draw(|f| render_metrics_panel(f, f.area(), &state))
6999            .unwrap();
7000    }
7001
7002    // ========================================================================
7003    // Phase 7: Alert System tests
7004    // ========================================================================
7005
7006    #[test]
7007    fn test_alert_config_default() {
7008        let config = AlertConfig::default();
7009        assert!(config.price_min.is_none());
7010        assert!(config.price_max.is_none());
7011        assert!(config.whale_min_usd.is_none());
7012        assert!(config.volume_spike_threshold_pct.is_none());
7013    }
7014
7015    #[test]
7016    fn test_alert_price_min_triggers() {
7017        let token_data = create_test_token_data();
7018        let mut state = MonitorState::new(&token_data, "ethereum");
7019        state.alerts.price_min = Some(2.0); // Price is 1.0, below min of 2.0
7020        state.update(&token_data);
7021        assert!(
7022            !state.active_alerts.is_empty(),
7023            "Should have price-min alert"
7024        );
7025        assert!(state.active_alerts[0].message.contains("below min"));
7026    }
7027
7028    #[test]
7029    fn test_alert_price_max_triggers() {
7030        let mut token_data = create_test_token_data();
7031        token_data.price_usd = 100.0;
7032        let mut state = MonitorState::new(&token_data, "ethereum");
7033        state.alerts.price_max = Some(50.0); // Price 100.0 above max of 50.0
7034        state.update(&token_data);
7035        assert!(
7036            !state.active_alerts.is_empty(),
7037            "Should have price-max alert"
7038        );
7039        assert!(state.active_alerts[0].message.contains("above max"));
7040    }
7041
7042    #[test]
7043    fn test_alert_no_trigger_within_bounds() {
7044        let token_data = create_test_token_data();
7045        let mut state = MonitorState::new(&token_data, "ethereum");
7046        state.alerts.price_min = Some(0.5); // Price 1.0 is above min
7047        state.alerts.price_max = Some(2.0); // Price 1.0 is below max
7048        state.update(&token_data);
7049        assert!(
7050            state.active_alerts.is_empty(),
7051            "Should have no alerts when price is within bounds"
7052        );
7053    }
7054
7055    #[test]
7056    fn test_alert_volume_spike_triggers() {
7057        let token_data = create_test_token_data();
7058        let mut state = MonitorState::new(&token_data, "ethereum");
7059        state.alerts.volume_spike_threshold_pct = Some(10.0);
7060        state.volume_avg = 500_000.0; // Average volume is 500K
7061
7062        // Token data has volume_24h of 1M, which is +100% vs avg — should trigger
7063        state.update(&token_data);
7064        let spike_alerts: Vec<_> = state
7065            .active_alerts
7066            .iter()
7067            .filter(|a| a.message.contains("spike"))
7068            .collect();
7069        assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
7070    }
7071
7072    #[test]
7073    fn test_alert_flash_timer_set() {
7074        let token_data = create_test_token_data();
7075        let mut state = MonitorState::new(&token_data, "ethereum");
7076        state.alerts.price_min = Some(2.0);
7077        state.update(&token_data);
7078        assert!(state.alert_flash_until.is_some());
7079    }
7080
7081    #[test]
7082    fn test_render_alert_overlay_no_panic() {
7083        let mut terminal = create_test_terminal();
7084        let mut state = create_populated_state();
7085        state.active_alerts.push(ActiveAlert {
7086            message: "⚠ Test alert".to_string(),
7087            triggered_at: Instant::now(),
7088        });
7089        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7090        terminal
7091            .draw(|f| render_alert_overlay(f, f.area(), &state))
7092            .unwrap();
7093    }
7094
7095    #[test]
7096    fn test_render_alert_overlay_empty() {
7097        let mut terminal = create_test_terminal();
7098        let state = create_populated_state();
7099        terminal
7100            .draw(|f| render_alert_overlay(f, f.area(), &state))
7101            .unwrap();
7102    }
7103
7104    #[test]
7105    fn test_alert_config_serde_roundtrip() {
7106        let config = AlertConfig {
7107            price_min: Some(0.5),
7108            price_max: Some(2.0),
7109            whale_min_usd: Some(10_000.0),
7110            volume_spike_threshold_pct: Some(50.0),
7111        };
7112        let yaml = serde_yaml::to_string(&config).unwrap();
7113        let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
7114        assert_eq!(parsed.price_min, Some(0.5));
7115        assert_eq!(parsed.price_max, Some(2.0));
7116        assert_eq!(parsed.whale_min_usd, Some(10_000.0));
7117        assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
7118    }
7119
7120    #[test]
7121    fn test_ui_with_active_alerts() {
7122        let mut terminal = create_test_terminal();
7123        let mut state = create_populated_state();
7124        state.active_alerts.push(ActiveAlert {
7125            message: "⚠ Price below min".to_string(),
7126            triggered_at: Instant::now(),
7127        });
7128        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7129        terminal.draw(|f| ui(f, &mut state)).unwrap();
7130    }
7131
7132    // ========================================================================
7133    // Phase 8: CSV Export tests
7134    // ========================================================================
7135
7136    #[test]
7137    fn test_export_config_default() {
7138        let config = ExportConfig::default();
7139        assert!(config.path.is_none());
7140    }
7141
7142    /// Helper to create an export in a temp directory, avoiding race conditions.
7143    fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
7144        use std::sync::atomic::{AtomicU64, Ordering};
7145        static COUNTER: AtomicU64 = AtomicU64::new(0);
7146        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
7147        let dir =
7148            std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
7149        let _ = fs::create_dir_all(&dir);
7150        let filename = format!("{}_test_{}.csv", state.symbol, id);
7151        let path = dir.join(filename);
7152
7153        let mut file = fs::File::create(&path).expect("failed to create export test file");
7154        let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
7155        file.write_all(header.as_bytes())
7156            .expect("failed to write header");
7157        drop(file); // Ensure file is flushed and closed
7158
7159        state.export_path = Some(path.clone());
7160        state.export_active = true;
7161        path
7162    }
7163
7164    #[test]
7165    fn test_export_start_creates_file() {
7166        let token_data = create_test_token_data();
7167        let mut state = MonitorState::new(&token_data, "ethereum");
7168        let path = start_export_in_temp(&mut state);
7169
7170        assert!(state.export_active);
7171        assert!(state.export_path.is_some());
7172        assert!(path.exists(), "Export file should exist");
7173
7174        // Cleanup
7175        let _ = std::fs::remove_file(&path);
7176    }
7177
7178    #[test]
7179    fn test_export_stop() {
7180        let token_data = create_test_token_data();
7181        let mut state = MonitorState::new(&token_data, "ethereum");
7182        let path = start_export_in_temp(&mut state);
7183        state.stop_export();
7184
7185        assert!(!state.export_active);
7186        assert!(state.export_path.is_none());
7187
7188        // Cleanup
7189        let _ = std::fs::remove_file(&path);
7190    }
7191
7192    #[test]
7193    fn test_export_toggle() {
7194        let token_data = create_test_token_data();
7195        let mut state = MonitorState::new(&token_data, "ethereum");
7196
7197        state.toggle_export();
7198        assert!(state.export_active);
7199        let path = state.export_path.clone().unwrap();
7200
7201        state.toggle_export();
7202        assert!(!state.export_active);
7203
7204        // Cleanup
7205        let _ = std::fs::remove_file(path);
7206    }
7207
7208    #[test]
7209    fn test_export_writes_csv_rows() {
7210        let token_data = create_test_token_data();
7211        let mut state = MonitorState::new(&token_data, "ethereum");
7212        let path = start_export_in_temp(&mut state);
7213
7214        // Simulate a few updates
7215        state.update(&token_data);
7216        state.update(&token_data);
7217
7218        let contents = std::fs::read_to_string(&path).unwrap();
7219        let lines: Vec<&str> = contents.lines().collect();
7220
7221        assert!(
7222            lines.len() >= 3,
7223            "Should have header + 2 data rows, got {}",
7224            lines.len()
7225        );
7226        assert!(lines[0].starts_with("timestamp,price_usd"));
7227
7228        // Cleanup
7229        state.stop_export();
7230        let _ = std::fs::remove_file(path);
7231    }
7232
7233    #[test]
7234    fn test_keybinding_e_toggles_export() {
7235        let token_data = create_test_token_data();
7236        let mut state = MonitorState::new(&token_data, "ethereum");
7237
7238        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7239        assert!(state.export_active);
7240        let path = state.export_path.clone().unwrap();
7241
7242        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7243        assert!(!state.export_active);
7244
7245        // Cleanup
7246        let _ = std::fs::remove_file(path);
7247    }
7248
7249    #[test]
7250    fn test_render_footer_with_export_active() {
7251        let mut terminal = create_test_terminal();
7252        let mut state = create_populated_state();
7253        state.export_active = true;
7254        terminal
7255            .draw(|f| render_footer(f, f.area(), &state))
7256            .unwrap();
7257    }
7258
7259    #[test]
7260    fn test_export_config_serde_roundtrip() {
7261        let config = ExportConfig {
7262            path: Some("./my-exports".to_string()),
7263        };
7264        let yaml = serde_yaml::to_string(&config).unwrap();
7265        let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
7266        assert_eq!(parsed.path, Some("./my-exports".to_string()));
7267    }
7268
7269    // ========================================================================
7270    // Phase 9: Auto-Pause tests
7271    // ========================================================================
7272
7273    #[test]
7274    fn test_auto_pause_default_disabled() {
7275        let token_data = create_test_token_data();
7276        let state = MonitorState::new(&token_data, "ethereum");
7277        assert!(!state.auto_pause_on_input);
7278        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7279    }
7280
7281    #[test]
7282    fn test_auto_pause_blocks_refresh() {
7283        let token_data = create_test_token_data();
7284        let mut state = MonitorState::new(&token_data, "ethereum");
7285        state.auto_pause_on_input = true;
7286        state.refresh_rate = Duration::from_secs(1);
7287
7288        // Simulate fresh input
7289        state.last_input_at = Instant::now();
7290        state.last_update = Instant::now() - Duration::from_secs(10); // Long overdue
7291
7292        // Auto-pause should block refresh since we just had input
7293        assert!(!state.should_refresh());
7294    }
7295
7296    #[test]
7297    fn test_auto_pause_allows_refresh_after_timeout() {
7298        let token_data = create_test_token_data();
7299        let mut state = MonitorState::new(&token_data, "ethereum");
7300        state.auto_pause_on_input = true;
7301        state.refresh_rate = Duration::from_secs(1);
7302        state.auto_pause_timeout = Duration::from_millis(1); // Very short timeout
7303
7304        // Simulate old input (long ago)
7305        state.last_input_at = Instant::now() - Duration::from_secs(10);
7306        state.last_update = Instant::now() - Duration::from_secs(10);
7307
7308        // Should allow refresh since input was long ago
7309        assert!(state.should_refresh());
7310    }
7311
7312    #[test]
7313    fn test_auto_pause_disabled_does_not_block() {
7314        let token_data = create_test_token_data();
7315        let mut state = MonitorState::new(&token_data, "ethereum");
7316        state.auto_pause_on_input = false;
7317        state.refresh_rate = Duration::from_secs(1);
7318
7319        state.last_input_at = Instant::now(); // Fresh input
7320        state.last_update = Instant::now() - Duration::from_secs(10);
7321
7322        // Should still refresh because auto-pause is disabled
7323        assert!(state.should_refresh());
7324    }
7325
7326    #[test]
7327    fn test_is_auto_paused() {
7328        let token_data = create_test_token_data();
7329        let mut state = MonitorState::new(&token_data, "ethereum");
7330
7331        // Not auto-paused when disabled
7332        state.auto_pause_on_input = false;
7333        state.last_input_at = Instant::now();
7334        assert!(!state.is_auto_paused());
7335
7336        // Auto-paused when enabled and input is recent
7337        state.auto_pause_on_input = true;
7338        state.last_input_at = Instant::now();
7339        assert!(state.is_auto_paused());
7340
7341        // Not auto-paused when input is old
7342        state.last_input_at = Instant::now() - Duration::from_secs(10);
7343        assert!(!state.is_auto_paused());
7344    }
7345
7346    #[test]
7347    fn test_keybinding_shift_p_toggles_auto_pause() {
7348        let token_data = create_test_token_data();
7349        let mut state = MonitorState::new(&token_data, "ethereum");
7350        assert!(!state.auto_pause_on_input);
7351
7352        let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
7353        handle_key_event_on_state(shift_p, &mut state);
7354        assert!(state.auto_pause_on_input);
7355
7356        handle_key_event_on_state(shift_p, &mut state);
7357        assert!(!state.auto_pause_on_input);
7358    }
7359
7360    #[test]
7361    fn test_keybinding_updates_last_input_at() {
7362        let token_data = create_test_token_data();
7363        let mut state = MonitorState::new(&token_data, "ethereum");
7364
7365        // Set last_input_at to the past
7366        state.last_input_at = Instant::now() - Duration::from_secs(60);
7367        let old_input = state.last_input_at;
7368
7369        // Any key event should update last_input_at
7370        handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
7371        assert!(state.last_input_at > old_input);
7372    }
7373
7374    #[test]
7375    fn test_render_footer_auto_paused() {
7376        let mut terminal = create_test_terminal();
7377        let mut state = create_populated_state();
7378        state.auto_pause_on_input = true;
7379        state.last_input_at = Instant::now(); // Recent input -> auto-paused
7380        terminal
7381            .draw(|f| render_footer(f, f.area(), &state))
7382            .unwrap();
7383    }
7384
7385    #[test]
7386    fn test_config_auto_pause_applied() {
7387        let mut state = create_populated_state();
7388        let config = MonitorConfig {
7389            auto_pause_on_input: true,
7390            ..MonitorConfig::default()
7391        };
7392        state.apply_config(&config);
7393        assert!(state.auto_pause_on_input);
7394    }
7395
7396    // ========================================================================
7397    // Combined full-UI tests for new features
7398    // ========================================================================
7399
7400    #[test]
7401    fn test_ui_render_all_layouts_with_alerts_and_export() {
7402        for preset in &[
7403            LayoutPreset::Dashboard,
7404            LayoutPreset::ChartFocus,
7405            LayoutPreset::Feed,
7406            LayoutPreset::Compact,
7407        ] {
7408            let mut terminal = create_test_terminal();
7409            let mut state = create_populated_state();
7410            state.layout = *preset;
7411            state.auto_layout = false;
7412            state.export_active = true;
7413            state.active_alerts.push(ActiveAlert {
7414                message: "⚠ Test alert".to_string(),
7415                triggered_at: Instant::now(),
7416            });
7417            terminal.draw(|f| ui(f, &mut state)).unwrap();
7418        }
7419    }
7420
7421    #[test]
7422    fn test_ui_render_with_liquidity_data() {
7423        let mut terminal = create_test_terminal();
7424        let mut state = create_populated_state();
7425        state.liquidity_pairs = vec![
7426            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
7427            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
7428        ];
7429        terminal.draw(|f| ui(f, &mut state)).unwrap();
7430    }
7431
7432    #[test]
7433    fn test_monitor_config_full_serde_roundtrip() {
7434        let config = MonitorConfig {
7435            layout: LayoutPreset::Dashboard,
7436            refresh_seconds: 10,
7437            widgets: WidgetVisibility::default(),
7438            scale: ScaleMode::Log,
7439            color_scheme: ColorScheme::BlueOrange,
7440            alerts: AlertConfig {
7441                price_min: Some(0.5),
7442                price_max: Some(10.0),
7443                whale_min_usd: Some(50_000.0),
7444                volume_spike_threshold_pct: Some(100.0),
7445            },
7446            export: ExportConfig {
7447                path: Some("./exports".to_string()),
7448            },
7449            auto_pause_on_input: true,
7450            venue: Some("binance".to_string()),
7451        };
7452
7453        let yaml = serde_yaml::to_string(&config).unwrap();
7454        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
7455        assert_eq!(parsed.layout, LayoutPreset::Dashboard);
7456        assert_eq!(parsed.refresh_seconds, 10);
7457        assert_eq!(parsed.venue, Some("binance".to_string()));
7458        assert_eq!(parsed.alerts.price_min, Some(0.5));
7459        assert_eq!(parsed.alerts.price_max, Some(10.0));
7460        assert_eq!(parsed.export.path, Some("./exports".to_string()));
7461        assert!(parsed.auto_pause_on_input);
7462    }
7463
7464    #[test]
7465    fn test_monitor_config_serde_defaults_for_new_fields() {
7466        // Only specify old fields — new fields should default
7467        let yaml = r#"
7468layout: dashboard
7469refresh_seconds: 5
7470"#;
7471        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
7472        assert!(config.alerts.price_min.is_none());
7473        assert!(config.export.path.is_none());
7474        assert!(!config.auto_pause_on_input);
7475    }
7476
7477    #[test]
7478    fn test_quit_stops_export() {
7479        let token_data = create_test_token_data();
7480        let mut state = MonitorState::new(&token_data, "ethereum");
7481        let path = start_export_in_temp(&mut state);
7482
7483        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
7484        assert!(exit);
7485        assert!(!state.export_active);
7486
7487        // Cleanup
7488        let _ = std::fs::remove_file(path);
7489    }
7490
7491    #[test]
7492    fn test_monitor_state_new_has_alert_export_autopause_fields() {
7493        let token_data = create_test_token_data();
7494        let state = MonitorState::new(&token_data, "ethereum");
7495
7496        // Alert fields
7497        assert!(state.active_alerts.is_empty());
7498        assert!(state.alert_flash_until.is_none());
7499        assert!(state.alerts.price_min.is_none());
7500
7501        // Export fields
7502        assert!(!state.export_active);
7503        assert!(state.export_path.is_none());
7504
7505        // Auto-pause fields
7506        assert!(!state.auto_pause_on_input);
7507        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7508    }
7509
7510    // ========================================================================
7511    // Coverage gap: Serde round-trip tests for enums
7512    // ========================================================================
7513
7514    #[test]
7515    fn test_scale_mode_serde_roundtrip() {
7516        for mode in &[ScaleMode::Linear, ScaleMode::Log] {
7517            let yaml = serde_yaml::to_string(mode).unwrap();
7518            let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
7519            assert_eq!(&parsed, mode);
7520        }
7521    }
7522
7523    #[test]
7524    fn test_scale_mode_serde_kebab_case() {
7525        let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
7526        assert_eq!(parsed, ScaleMode::Linear);
7527        let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
7528        assert_eq!(parsed, ScaleMode::Log);
7529    }
7530
7531    #[test]
7532    fn test_scale_mode_toggle() {
7533        assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
7534        assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
7535    }
7536
7537    #[test]
7538    fn test_scale_mode_label() {
7539        assert_eq!(ScaleMode::Linear.label(), "Lin");
7540        assert_eq!(ScaleMode::Log.label(), "Log");
7541    }
7542
7543    #[test]
7544    fn test_color_scheme_serde_roundtrip() {
7545        for scheme in &[
7546            ColorScheme::GreenRed,
7547            ColorScheme::BlueOrange,
7548            ColorScheme::Monochrome,
7549        ] {
7550            let yaml = serde_yaml::to_string(scheme).unwrap();
7551            let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
7552            assert_eq!(&parsed, scheme);
7553        }
7554    }
7555
7556    #[test]
7557    fn test_color_scheme_serde_kebab_case() {
7558        let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
7559        assert_eq!(parsed, ColorScheme::GreenRed);
7560        let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
7561        assert_eq!(parsed, ColorScheme::BlueOrange);
7562        let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
7563        assert_eq!(parsed, ColorScheme::Monochrome);
7564    }
7565
7566    #[test]
7567    fn test_color_scheme_cycle() {
7568        assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
7569        assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
7570        assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
7571    }
7572
7573    #[test]
7574    fn test_color_scheme_label() {
7575        assert_eq!(ColorScheme::GreenRed.label(), "G/R");
7576        assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
7577        assert_eq!(ColorScheme::Monochrome.label(), "Mono");
7578    }
7579
7580    #[test]
7581    fn test_color_palette_fields_populated() {
7582        // Verify each palette has distinct meaningful values
7583        for scheme in &[
7584            ColorScheme::GreenRed,
7585            ColorScheme::BlueOrange,
7586            ColorScheme::Monochrome,
7587        ] {
7588            let pal = scheme.palette();
7589            // up and down colors should differ (visually distinct)
7590            assert_ne!(
7591                format!("{:?}", pal.up),
7592                format!("{:?}", pal.down),
7593                "Up/down should differ for {:?}",
7594                scheme
7595            );
7596        }
7597    }
7598
7599    #[test]
7600    fn test_layout_preset_serde_roundtrip() {
7601        for preset in &[
7602            LayoutPreset::Dashboard,
7603            LayoutPreset::ChartFocus,
7604            LayoutPreset::Feed,
7605            LayoutPreset::Compact,
7606        ] {
7607            let yaml = serde_yaml::to_string(preset).unwrap();
7608            let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
7609            assert_eq!(&parsed, preset);
7610        }
7611    }
7612
7613    #[test]
7614    fn test_layout_preset_serde_kebab_case() {
7615        let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
7616        assert_eq!(parsed, LayoutPreset::Dashboard);
7617        let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
7618        assert_eq!(parsed, LayoutPreset::ChartFocus);
7619        let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
7620        assert_eq!(parsed, LayoutPreset::Feed);
7621        let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
7622        assert_eq!(parsed, LayoutPreset::Compact);
7623    }
7624
7625    #[test]
7626    fn test_widget_visibility_serde_roundtrip() {
7627        let vis = WidgetVisibility {
7628            price_chart: false,
7629            volume_chart: true,
7630            buy_sell_pressure: false,
7631            metrics_panel: true,
7632            activity_log: false,
7633            holder_count: false,
7634            liquidity_depth: true,
7635        };
7636        let yaml = serde_yaml::to_string(&vis).unwrap();
7637        let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
7638        assert!(!parsed.price_chart);
7639        assert!(parsed.volume_chart);
7640        assert!(!parsed.buy_sell_pressure);
7641        assert!(parsed.metrics_panel);
7642        assert!(!parsed.activity_log);
7643        assert!(!parsed.holder_count);
7644        assert!(parsed.liquidity_depth);
7645    }
7646
7647    #[test]
7648    fn test_data_point_serde_roundtrip() {
7649        let dp = DataPoint {
7650            timestamp: 1700000000.5,
7651            value: 42.123456,
7652            is_real: true,
7653        };
7654        let json = serde_json::to_string(&dp).unwrap();
7655        let parsed: DataPoint = serde_json::from_str(&json).unwrap();
7656        assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
7657        assert!((parsed.value - dp.value).abs() < 0.001);
7658        assert_eq!(parsed.is_real, dp.is_real);
7659    }
7660
7661    // ========================================================================
7662    // Coverage gap: Key handler tests for scale and color scheme
7663    // ========================================================================
7664
7665    #[test]
7666    fn test_handle_key_scale_toggle_s() {
7667        let token_data = create_test_token_data();
7668        let mut state = MonitorState::new(&token_data, "ethereum");
7669        assert_eq!(state.scale_mode, ScaleMode::Linear);
7670
7671        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7672        assert_eq!(state.scale_mode, ScaleMode::Log);
7673
7674        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7675        assert_eq!(state.scale_mode, ScaleMode::Linear);
7676    }
7677
7678    #[test]
7679    fn test_handle_key_color_scheme_cycle_slash() {
7680        let token_data = create_test_token_data();
7681        let mut state = MonitorState::new(&token_data, "ethereum");
7682        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7683
7684        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7685        assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
7686
7687        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7688        assert_eq!(state.color_scheme, ColorScheme::Monochrome);
7689
7690        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7691        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7692    }
7693
7694    // ========================================================================
7695    // Coverage gap: Volume profile chart render tests
7696    // ========================================================================
7697
7698    #[test]
7699    fn test_render_volume_profile_chart_no_panic() {
7700        let mut terminal = create_test_terminal();
7701        let state = create_populated_state();
7702        terminal
7703            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7704            .unwrap();
7705    }
7706
7707    #[test]
7708    fn test_render_volume_profile_chart_empty_data() {
7709        let mut terminal = create_test_terminal();
7710        let token_data = create_test_token_data();
7711        let mut state = MonitorState::new(&token_data, "ethereum");
7712        state.price_history.clear();
7713        state.volume_history.clear();
7714        terminal
7715            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7716            .unwrap();
7717    }
7718
7719    #[test]
7720    fn test_render_volume_profile_chart_single_price() {
7721        // When all prices are identical, there's no range -> "no price range" path
7722        let mut terminal = create_test_terminal();
7723        let mut token_data = create_test_token_data();
7724        token_data.price_usd = 1.0;
7725        let mut state = MonitorState::new(&token_data, "ethereum");
7726        // Clear and add identical-price data points
7727        state.price_history.clear();
7728        state.volume_history.clear();
7729        let now = chrono::Utc::now().timestamp() as f64;
7730        for i in 0..5 {
7731            state.price_history.push_back(DataPoint {
7732                timestamp: now - (5.0 - i as f64) * 60.0,
7733                value: 1.0, // all same price
7734                is_real: true,
7735            });
7736            state.volume_history.push_back(DataPoint {
7737                timestamp: now - (5.0 - i as f64) * 60.0,
7738                value: 1000.0,
7739                is_real: true,
7740            });
7741        }
7742        terminal
7743            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7744            .unwrap();
7745    }
7746
7747    #[test]
7748    fn test_render_volume_profile_chart_narrow_terminal() {
7749        let backend = TestBackend::new(30, 15);
7750        let mut terminal = Terminal::new(backend).unwrap();
7751        let state = create_populated_state();
7752        terminal
7753            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7754            .unwrap();
7755    }
7756
7757    // ========================================================================
7758    // Coverage gap: Log scale rendering tests
7759    // ========================================================================
7760
7761    #[test]
7762    fn test_render_price_chart_log_scale() {
7763        let mut terminal = create_test_terminal();
7764        let mut state = create_populated_state();
7765        state.scale_mode = ScaleMode::Log;
7766        terminal
7767            .draw(|f| render_price_chart(f, f.area(), &state))
7768            .unwrap();
7769    }
7770
7771    #[test]
7772    fn test_render_candlestick_chart_log_scale() {
7773        let mut terminal = create_test_terminal();
7774        let mut state = create_populated_state();
7775        state.scale_mode = ScaleMode::Log;
7776        state.chart_mode = ChartMode::Candlestick;
7777        terminal
7778            .draw(|f| render_candlestick_chart(f, f.area(), &state))
7779            .unwrap();
7780    }
7781
7782    #[test]
7783    fn test_render_price_chart_log_scale_zero_price() {
7784        // Verify log scale handles zero/near-zero prices safely
7785        let mut terminal = create_test_terminal();
7786        let mut token_data = create_test_token_data();
7787        token_data.price_usd = 0.0001;
7788        let mut state = MonitorState::new(&token_data, "ethereum");
7789        state.scale_mode = ScaleMode::Log;
7790        for i in 0..10 {
7791            let mut data = token_data.clone();
7792            data.price_usd = 0.0001 + (i as f64 * 0.00001);
7793            state.update(&data);
7794        }
7795        terminal
7796            .draw(|f| render_price_chart(f, f.area(), &state))
7797            .unwrap();
7798    }
7799
7800    // ========================================================================
7801    // Coverage gap: Color scheme rendering tests
7802    // ========================================================================
7803
7804    #[test]
7805    fn test_render_ui_with_all_color_schemes() {
7806        for scheme in &[
7807            ColorScheme::GreenRed,
7808            ColorScheme::BlueOrange,
7809            ColorScheme::Monochrome,
7810        ] {
7811            let mut terminal = create_test_terminal();
7812            let mut state = create_populated_state();
7813            state.color_scheme = *scheme;
7814            terminal.draw(|f| ui(f, &mut state)).unwrap();
7815        }
7816    }
7817
7818    #[test]
7819    fn test_render_volume_chart_all_color_schemes() {
7820        for scheme in &[
7821            ColorScheme::GreenRed,
7822            ColorScheme::BlueOrange,
7823            ColorScheme::Monochrome,
7824        ] {
7825            let mut terminal = create_test_terminal();
7826            let mut state = create_populated_state();
7827            state.color_scheme = *scheme;
7828            terminal
7829                .draw(|f| render_volume_chart(f, f.area(), &state))
7830                .unwrap();
7831        }
7832    }
7833
7834    // ========================================================================
7835    // Coverage gap: Activity feed dedicated render tests
7836    // ========================================================================
7837
7838    #[test]
7839    fn test_render_activity_feed_no_panic() {
7840        let mut terminal = create_test_terminal();
7841        let mut state = create_populated_state();
7842        for i in 0..5 {
7843            state.log_messages.push_back(format!("Event {}", i));
7844        }
7845        terminal
7846            .draw(|f| render_activity_feed(f, f.area(), &mut state))
7847            .unwrap();
7848    }
7849
7850    #[test]
7851    fn test_render_activity_feed_empty_log() {
7852        let mut terminal = create_test_terminal();
7853        let token_data = create_test_token_data();
7854        let mut state = MonitorState::new(&token_data, "ethereum");
7855        state.log_messages.clear();
7856        terminal
7857            .draw(|f| render_activity_feed(f, f.area(), &mut state))
7858            .unwrap();
7859    }
7860
7861    #[test]
7862    fn test_render_activity_feed_with_selection() {
7863        let mut terminal = create_test_terminal();
7864        let mut state = create_populated_state();
7865        for i in 0..10 {
7866            state.log_messages.push_back(format!("Event {}", i));
7867        }
7868        state.scroll_log_down();
7869        state.scroll_log_down();
7870        state.scroll_log_down();
7871        terminal
7872            .draw(|f| render_activity_feed(f, f.area(), &mut state))
7873            .unwrap();
7874    }
7875
7876    // ========================================================================
7877    // Coverage gap: Alert edge cases
7878    // ========================================================================
7879
7880    #[test]
7881    fn test_alert_whale_zero_transactions() {
7882        let mut token_data = create_test_token_data();
7883        token_data.total_buys_24h = 0;
7884        token_data.total_sells_24h = 0;
7885        let mut state = MonitorState::new(&token_data, "ethereum");
7886        state.alerts.whale_min_usd = Some(100.0);
7887        state.update(&token_data);
7888        // With zero total txs, whale detection should NOT fire
7889        let whale_alerts: Vec<_> = state
7890            .active_alerts
7891            .iter()
7892            .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
7893            .collect();
7894        assert!(
7895            whale_alerts.is_empty(),
7896            "Whale alert should not fire with zero transactions"
7897        );
7898    }
7899
7900    #[test]
7901    fn test_alert_multiple_simultaneous() {
7902        let mut token_data = create_test_token_data();
7903        token_data.price_usd = 0.1; // below min
7904        let mut state = MonitorState::new(&token_data, "ethereum");
7905        state.alerts.price_min = Some(0.5); // will fire: price 0.1 < 0.5
7906        state.alerts.price_max = Some(0.05); // will fire: price 0.1 > 0.05
7907        state.alerts.volume_spike_threshold_pct = Some(1.0);
7908        state.volume_avg = 100.0; // volume_24h 1M vs avg 100 => huge spike
7909
7910        state.update(&token_data);
7911        // Should have multiple alerts
7912        assert!(
7913            state.active_alerts.len() >= 2,
7914            "Expected multiple alerts, got {}",
7915            state.active_alerts.len()
7916        );
7917    }
7918
7919    #[test]
7920    fn test_alert_clears_on_next_update() {
7921        let token_data = create_test_token_data();
7922        let mut state = MonitorState::new(&token_data, "ethereum");
7923        state.alerts.price_min = Some(2.0); // price 1.0 < 2.0 -> fires
7924        state.update(&token_data);
7925        assert!(!state.active_alerts.is_empty());
7926
7927        // Update with price above min -> should clear
7928        let mut above_min = token_data.clone();
7929        above_min.price_usd = 3.0;
7930        state.alerts.price_min = Some(2.0);
7931        state.update(&above_min);
7932        // check_alerts clears alerts each time and re-evaluates
7933        let price_min_alerts: Vec<_> = state
7934            .active_alerts
7935            .iter()
7936            .filter(|a| a.message.contains("below min"))
7937            .collect();
7938        assert!(
7939            price_min_alerts.is_empty(),
7940            "Price-min alert should clear when price goes above min"
7941        );
7942    }
7943
7944    #[test]
7945    fn test_render_alert_overlay_multiple_alerts() {
7946        let mut terminal = create_test_terminal();
7947        let mut state = create_populated_state();
7948        state.active_alerts.push(ActiveAlert {
7949            message: "⚠ Price below min".to_string(),
7950            triggered_at: Instant::now(),
7951        });
7952        state.active_alerts.push(ActiveAlert {
7953            message: "🐋 Whale detected".to_string(),
7954            triggered_at: Instant::now(),
7955        });
7956        state.active_alerts.push(ActiveAlert {
7957            message: "⚠ Volume spike".to_string(),
7958            triggered_at: Instant::now(),
7959        });
7960        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7961        terminal
7962            .draw(|f| render_alert_overlay(f, f.area(), &state))
7963            .unwrap();
7964    }
7965
7966    #[test]
7967    fn test_render_alert_overlay_flash_expired() {
7968        let mut terminal = create_test_terminal();
7969        let mut state = create_populated_state();
7970        state.active_alerts.push(ActiveAlert {
7971            message: "⚠ Test".to_string(),
7972            triggered_at: Instant::now(),
7973        });
7974        // Flash timer already expired
7975        state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
7976        terminal
7977            .draw(|f| render_alert_overlay(f, f.area(), &state))
7978            .unwrap();
7979    }
7980
7981    // ========================================================================
7982    // Coverage gap: Liquidity depth edge cases
7983    // ========================================================================
7984
7985    #[test]
7986    fn test_render_liquidity_depth_many_pairs() {
7987        let mut terminal = create_test_terminal();
7988        let mut state = create_populated_state();
7989        // Add many pairs to test height-limiting
7990        for i in 0..20 {
7991            state.liquidity_pairs.push((
7992                format!("TEST/TOKEN{} (DEX{})", i, i),
7993                (100_000.0 + i as f64 * 50_000.0),
7994            ));
7995        }
7996        terminal
7997            .draw(|f| render_liquidity_depth(f, f.area(), &state))
7998            .unwrap();
7999    }
8000
8001    #[test]
8002    fn test_render_liquidity_depth_narrow_terminal() {
8003        let backend = TestBackend::new(30, 10);
8004        let mut terminal = Terminal::new(backend).unwrap();
8005        let mut state = create_populated_state();
8006        state.liquidity_pairs = vec![
8007            ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
8008            ("TEST/USDC (Sushi)".to_string(), 100_000.0),
8009        ];
8010        terminal
8011            .draw(|f| render_liquidity_depth(f, f.area(), &state))
8012            .unwrap();
8013    }
8014
8015    // ========================================================================
8016    // Coverage gap: Metrics panel edge cases
8017    // ========================================================================
8018
8019    #[test]
8020    fn test_render_metrics_panel_holder_count_disabled() {
8021        let mut terminal = create_test_terminal();
8022        let mut state = create_populated_state();
8023        state.holder_count = Some(42_000);
8024        state.widgets.holder_count = false; // disabled
8025        terminal
8026            .draw(|f| render_metrics_panel(f, f.area(), &state))
8027            .unwrap();
8028    }
8029
8030    #[test]
8031    fn test_render_metrics_panel_sparkline_single_point() {
8032        let mut terminal = create_test_terminal();
8033        let mut token_data = create_test_token_data();
8034        token_data.price_usd = 1.0;
8035        let mut state = MonitorState::new(&token_data, "ethereum");
8036        state.price_history.clear();
8037        state.price_history.push_back(DataPoint {
8038            timestamp: 1.0,
8039            value: 1.0,
8040            is_real: true,
8041        });
8042        terminal
8043            .draw(|f| render_metrics_panel(f, f.area(), &state))
8044            .unwrap();
8045    }
8046
8047    // ========================================================================
8048    // Coverage gap: Buy/sell gauge edge cases
8049    // ========================================================================
8050
8051    #[test]
8052    fn test_render_buy_sell_gauge_tiny_area() {
8053        // Render in a very small area to test zero width/height paths
8054        let backend = TestBackend::new(5, 3);
8055        let mut terminal = Terminal::new(backend).unwrap();
8056        let mut state = create_populated_state();
8057        terminal
8058            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
8059            .unwrap();
8060    }
8061
8062    // ========================================================================
8063    // Coverage gap: Log message queue overflow
8064    // ========================================================================
8065
8066    #[test]
8067    fn test_log_message_queue_overflow() {
8068        let token_data = create_test_token_data();
8069        let mut state = MonitorState::new(&token_data, "ethereum");
8070        // Add more than 10 messages (queue capacity)
8071        for i in 0..20 {
8072            state.toggle_pause(); // each toggle logs a message
8073            let _ = i;
8074        }
8075        assert!(
8076            state.log_messages.len() <= 10,
8077            "Log queue should cap at 10, got {}",
8078            state.log_messages.len()
8079        );
8080    }
8081
8082    // ========================================================================
8083    // Coverage gap: Export CSV row content verification
8084    // ========================================================================
8085
8086    #[test]
8087    fn test_export_writes_csv_row_content_format() {
8088        let token_data = create_test_token_data();
8089        let mut state = MonitorState::new(&token_data, "ethereum");
8090        let path = start_export_in_temp(&mut state);
8091
8092        state.update(&token_data);
8093
8094        let contents = std::fs::read_to_string(&path).unwrap();
8095        let lines: Vec<&str> = contents.lines().collect();
8096        assert!(lines.len() >= 2);
8097
8098        // Verify header
8099        assert_eq!(
8100            lines[0],
8101            "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
8102        );
8103
8104        // Verify data row has correct number of columns
8105        let data_cols: Vec<&str> = lines[1].split(',').collect();
8106        assert_eq!(
8107            data_cols.len(),
8108            7,
8109            "Expected 7 CSV columns, got {}",
8110            data_cols.len()
8111        );
8112
8113        // Verify timestamp format (ISO 8601)
8114        assert!(data_cols[0].contains('T'));
8115        assert!(data_cols[0].ends_with('Z'));
8116
8117        // Cleanup
8118        state.stop_export();
8119        let _ = std::fs::remove_file(path);
8120    }
8121
8122    #[test]
8123    fn test_export_writes_csv_row_market_cap_none() {
8124        let mut token_data = create_test_token_data();
8125        token_data.market_cap = None;
8126        let mut state = MonitorState::new(&token_data, "ethereum");
8127        let path = start_export_in_temp(&mut state);
8128
8129        state.update(&token_data);
8130
8131        let contents = std::fs::read_to_string(&path).unwrap();
8132        let lines: Vec<&str> = contents.lines().collect();
8133        assert!(lines.len() >= 2);
8134
8135        // Last column should be empty when market_cap is None
8136        let data_cols: Vec<&str> = lines[1].split(',').collect();
8137        assert_eq!(data_cols.len(), 7);
8138        assert!(
8139            data_cols[6].is_empty(),
8140            "Market cap column should be empty when None"
8141        );
8142
8143        // Cleanup
8144        state.stop_export();
8145        let _ = std::fs::remove_file(path);
8146    }
8147
8148    // ========================================================================
8149    // Coverage gap: Full UI render with log scale + all chart modes
8150    // ========================================================================
8151
8152    #[test]
8153    fn test_ui_render_log_scale_all_chart_modes() {
8154        for mode in &[
8155            ChartMode::Line,
8156            ChartMode::Candlestick,
8157            ChartMode::VolumeProfile,
8158        ] {
8159            let mut terminal = create_test_terminal();
8160            let mut state = create_populated_state();
8161            state.scale_mode = ScaleMode::Log;
8162            state.chart_mode = *mode;
8163            terminal.draw(|f| ui(f, &mut state)).unwrap();
8164        }
8165    }
8166
8167    // ========================================================================
8168    // Coverage gap: Footer rendering edge cases
8169    // ========================================================================
8170
8171    #[test]
8172    fn test_render_footer_widget_toggle_mode_active() {
8173        let mut terminal = create_test_terminal();
8174        let mut state = create_populated_state();
8175        state.widget_toggle_mode = true;
8176        terminal
8177            .draw(|f| render_footer(f, f.area(), &state))
8178            .unwrap();
8179    }
8180
8181    #[test]
8182    fn test_render_footer_all_status_indicators() {
8183        // Test with export + auto-pause + alerts simultaneously
8184        let mut terminal = create_test_terminal();
8185        let mut state = create_populated_state();
8186        state.export_active = true;
8187        state.auto_pause_on_input = true;
8188        state.last_input_at = Instant::now(); // triggers auto-paused display
8189        terminal
8190            .draw(|f| render_footer(f, f.area(), &state))
8191            .unwrap();
8192    }
8193
8194    // ========================================================================
8195    // Coverage gap: Synthetic data generation edge cases
8196    // ========================================================================
8197
8198    #[test]
8199    fn test_generate_synthetic_price_history_zero_price() {
8200        let mut token_data = create_test_token_data();
8201        token_data.price_usd = 0.0;
8202        token_data.price_change_1h = 0.0;
8203        token_data.price_change_6h = 0.0;
8204        token_data.price_change_24h = 0.0;
8205        let state = MonitorState::new(&token_data, "ethereum");
8206        // Should not panic with zero prices
8207        assert!(!state.price_history.is_empty());
8208    }
8209
8210    #[test]
8211    fn test_generate_synthetic_volume_history_zero_volume() {
8212        let mut token_data = create_test_token_data();
8213        token_data.volume_24h = 0.0;
8214        token_data.volume_6h = 0.0;
8215        token_data.volume_1h = 0.0;
8216        let state = MonitorState::new(&token_data, "ethereum");
8217        assert!(!state.volume_history.is_empty());
8218    }
8219
8220    #[test]
8221    fn test_generate_synthetic_order_book() {
8222        let pairs = vec![crate::chains::DexPair {
8223            dex_name: "Uniswap V3".to_string(),
8224            pair_address: "0xabc".to_string(),
8225            base_token: "PUSD".to_string(),
8226            quote_token: "USDT".to_string(),
8227            price_usd: 1.0,
8228            volume_24h: 50_000.0,
8229            liquidity_usd: 200_000.0,
8230            price_change_24h: 0.1,
8231            buys_24h: 100,
8232            sells_24h: 90,
8233            buys_6h: 30,
8234            sells_6h: 25,
8235            buys_1h: 10,
8236            sells_1h: 8,
8237            pair_created_at: None,
8238            url: None,
8239        }];
8240        let book = MonitorState::generate_synthetic_order_book(&pairs, "PUSD", 1.0, 200_000.0);
8241        assert!(book.is_some());
8242        let book = book.unwrap();
8243        assert_eq!(book.pair, "PUSD/USDT");
8244        assert!(!book.asks.is_empty());
8245        assert!(!book.bids.is_empty());
8246        // Asks should be ascending
8247        for w in book.asks.windows(2) {
8248            assert!(w[0].price <= w[1].price);
8249        }
8250        // Bids should be descending
8251        for w in book.bids.windows(2) {
8252            assert!(w[0].price >= w[1].price);
8253        }
8254    }
8255
8256    #[test]
8257    fn test_generate_synthetic_order_book_zero_price() {
8258        let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 0.0, 100_000.0);
8259        assert!(book.is_none());
8260    }
8261
8262    #[test]
8263    fn test_generate_synthetic_order_book_zero_liquidity() {
8264        let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 0.0);
8265        assert!(book.is_none());
8266    }
8267
8268    // ========================================================================
8269    // Coverage gap: Auto-pause with custom timeout
8270    // ========================================================================
8271
8272    #[test]
8273    fn test_auto_pause_custom_timeout() {
8274        let token_data = create_test_token_data();
8275        let mut state = MonitorState::new(&token_data, "ethereum");
8276        state.auto_pause_on_input = true;
8277        state.auto_pause_timeout = Duration::from_secs(10);
8278        state.refresh_rate = Duration::from_secs(1);
8279
8280        // Fresh input with long timeout -> still auto-paused
8281        state.last_input_at = Instant::now();
8282        state.last_update = Instant::now() - Duration::from_secs(5);
8283        assert!(!state.should_refresh()); // within 10s timeout
8284        assert!(state.is_auto_paused());
8285    }
8286
8287    // ========================================================================
8288    // Coverage gap: Price chart with stablecoin flat range
8289    // ========================================================================
8290
8291    #[test]
8292    fn test_render_price_chart_stablecoin_flat_range() {
8293        let mut terminal = create_test_terminal();
8294        let mut token_data = create_test_token_data();
8295        token_data.price_usd = 1.0;
8296        let mut state = MonitorState::new(&token_data, "ethereum");
8297        // Add many points at nearly identical prices (stablecoin)
8298        for i in 0..20 {
8299            let mut data = token_data.clone();
8300            data.price_usd = 1.0 + (i as f64 * 0.000001); // micro variation
8301            state.update(&data);
8302        }
8303        terminal
8304            .draw(|f| render_price_chart(f, f.area(), &state))
8305            .unwrap();
8306    }
8307
8308    // ========================================================================
8309    // Coverage gap: Cache load edge cases
8310    // ========================================================================
8311
8312    #[test]
8313    fn test_load_cache_corrupted_json() {
8314        let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
8315        // Write invalid JSON
8316        let _ = std::fs::write(&path, "not valid json {{{");
8317        let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
8318        assert!(cached.is_none(), "Corrupted JSON should return None");
8319        let _ = std::fs::remove_file(path);
8320    }
8321
8322    #[test]
8323    fn test_load_cache_wrong_token() {
8324        let token_data = create_test_token_data();
8325        let state = MonitorState::new(&token_data, "ethereum");
8326        state.save_cache();
8327
8328        // Try to load with different token address
8329        let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
8330        assert!(
8331            cached.is_none(),
8332            "Loading cache with wrong token address should return None"
8333        );
8334
8335        // Cleanup
8336        let path = MonitorState::cache_path(&token_data.address, "ethereum");
8337        let _ = std::fs::remove_file(path);
8338    }
8339
8340    // ========================================================================
8341    // Integration tests: Mock types and MonitorApp constructor
8342    // ========================================================================
8343
8344    use crate::chains::dex::TokenSearchResult;
8345
8346    /// Mock DEX data source for integration testing.
8347    struct MockDexDataSource {
8348        /// Data returned by `get_token_data`. If `Err`, simulates an API failure.
8349        token_data_result: std::sync::Mutex<Result<DexTokenData>>,
8350    }
8351
8352    impl MockDexDataSource {
8353        fn new(data: DexTokenData) -> Self {
8354            Self {
8355                token_data_result: std::sync::Mutex::new(Ok(data)),
8356            }
8357        }
8358
8359        fn failing(msg: &str) -> Self {
8360            Self {
8361                token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
8362            }
8363        }
8364    }
8365
8366    #[async_trait::async_trait]
8367    impl DexDataSource for MockDexDataSource {
8368        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
8369            self.token_data_result
8370                .lock()
8371                .unwrap()
8372                .as_ref()
8373                .ok()
8374                .map(|d| d.price_usd)
8375        }
8376
8377        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
8378            Some(2000.0)
8379        }
8380
8381        async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
8382            let guard = self.token_data_result.lock().unwrap();
8383            match &*guard {
8384                Ok(data) => Ok(data.clone()),
8385                Err(e) => Err(ScopeError::Api(e.to_string())),
8386            }
8387        }
8388
8389        async fn search_tokens(
8390            &self,
8391            _query: &str,
8392            _chain: Option<&str>,
8393        ) -> Result<Vec<TokenSearchResult>> {
8394            Ok(vec![])
8395        }
8396    }
8397
8398    /// Mock chain client for integration testing.
8399    struct MockChainClient {
8400        holder_count: u64,
8401    }
8402
8403    impl MockChainClient {
8404        fn new(holder_count: u64) -> Self {
8405            Self { holder_count }
8406        }
8407    }
8408
8409    #[async_trait::async_trait]
8410    impl ChainClient for MockChainClient {
8411        fn chain_name(&self) -> &str {
8412            "ethereum"
8413        }
8414        fn native_token_symbol(&self) -> &str {
8415            "ETH"
8416        }
8417        async fn get_balance(&self, _address: &str) -> Result<crate::chains::Balance> {
8418            unimplemented!("not needed for monitor tests")
8419        }
8420        async fn enrich_balance_usd(&self, _balance: &mut crate::chains::Balance) {}
8421        async fn get_transaction(&self, _hash: &str) -> Result<crate::chains::Transaction> {
8422            unimplemented!("not needed for monitor tests")
8423        }
8424        async fn get_transactions(
8425            &self,
8426            _address: &str,
8427            _limit: u32,
8428        ) -> Result<Vec<crate::chains::Transaction>> {
8429            Ok(vec![])
8430        }
8431        async fn get_block_number(&self) -> Result<u64> {
8432            Ok(1000000)
8433        }
8434        async fn get_token_balances(
8435            &self,
8436            _address: &str,
8437        ) -> Result<Vec<crate::chains::TokenBalance>> {
8438            Ok(vec![])
8439        }
8440        async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
8441            Ok(self.holder_count)
8442        }
8443    }
8444
8445    /// Creates a `MonitorApp<TestBackend>` with mock dependencies.
8446    fn create_test_app(
8447        dex: Box<dyn DexDataSource>,
8448        chain_client: Option<Box<dyn ChainClient>>,
8449    ) -> MonitorApp<TestBackend> {
8450        let token_data = create_test_token_data();
8451        let state = MonitorState::new(&token_data, "ethereum");
8452        let backend = TestBackend::new(120, 40);
8453        let terminal = ratatui::Terminal::new(backend).unwrap();
8454        MonitorApp {
8455            terminal,
8456            state,
8457            dex_client: dex,
8458            chain_client,
8459            exchange_client: None,
8460            should_exit: false,
8461            owns_terminal: false,
8462        }
8463    }
8464
8465    fn create_test_app_with_state(
8466        state: MonitorState,
8467        dex: Box<dyn DexDataSource>,
8468        chain_client: Option<Box<dyn ChainClient>>,
8469    ) -> MonitorApp<TestBackend> {
8470        let backend = TestBackend::new(120, 40);
8471        let terminal = ratatui::Terminal::new(backend).unwrap();
8472        MonitorApp {
8473            terminal,
8474            state,
8475            dex_client: dex,
8476            chain_client,
8477            exchange_client: None,
8478            should_exit: false,
8479            owns_terminal: false,
8480        }
8481    }
8482
8483    // ========================================================================
8484    // Integration tests: MonitorApp::handle_key_event
8485    // ========================================================================
8486
8487    #[test]
8488    fn test_app_handle_key_quit_q() {
8489        let data = create_test_token_data();
8490        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8491        assert!(!app.should_exit);
8492        app.handle_key_event(make_key_event(KeyCode::Char('q')));
8493        assert!(app.should_exit);
8494    }
8495
8496    #[test]
8497    fn test_app_handle_key_quit_esc() {
8498        let data = create_test_token_data();
8499        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8500        app.handle_key_event(make_key_event(KeyCode::Esc));
8501        assert!(app.should_exit);
8502    }
8503
8504    #[test]
8505    fn test_app_handle_key_quit_ctrl_c() {
8506        let data = create_test_token_data();
8507        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8508        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8509        app.handle_key_event(key);
8510        assert!(app.should_exit);
8511    }
8512
8513    #[test]
8514    fn test_app_handle_key_quit_stops_active_export() {
8515        let data = create_test_token_data();
8516        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8517        let path = start_export_in_temp(&mut app.state);
8518        assert!(app.state.export_active);
8519
8520        app.handle_key_event(make_key_event(KeyCode::Char('q')));
8521        assert!(app.should_exit);
8522        assert!(!app.state.export_active);
8523        let _ = std::fs::remove_file(path);
8524    }
8525
8526    #[test]
8527    fn test_app_handle_key_ctrl_c_stops_active_export() {
8528        let data = create_test_token_data();
8529        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8530        let path = start_export_in_temp(&mut app.state);
8531        assert!(app.state.export_active);
8532
8533        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8534        app.handle_key_event(key);
8535        assert!(app.should_exit);
8536        assert!(!app.state.export_active);
8537        let _ = std::fs::remove_file(path);
8538    }
8539
8540    #[test]
8541    fn test_app_handle_key_updates_last_input_time() {
8542        let data = create_test_token_data();
8543        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8544        let before = Instant::now();
8545        app.handle_key_event(make_key_event(KeyCode::Char('p')));
8546        assert!(app.state.last_input_at >= before);
8547    }
8548
8549    #[test]
8550    fn test_app_handle_key_widget_toggle_mode() {
8551        let data = create_test_token_data();
8552        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8553        assert!(app.state.widgets.price_chart);
8554
8555        // Enter widget toggle mode
8556        app.handle_key_event(make_key_event(KeyCode::Char('w')));
8557        assert!(app.state.widget_toggle_mode);
8558
8559        // Toggle widget 1 (price chart)
8560        app.handle_key_event(make_key_event(KeyCode::Char('1')));
8561        assert!(!app.state.widget_toggle_mode);
8562        assert!(!app.state.widgets.price_chart);
8563    }
8564
8565    #[test]
8566    fn test_app_handle_key_widget_toggle_mode_cancel() {
8567        let data = create_test_token_data();
8568        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8569
8570        // Enter widget toggle mode
8571        app.handle_key_event(make_key_event(KeyCode::Char('w')));
8572        assert!(app.state.widget_toggle_mode);
8573
8574        // Any non-digit key cancels widget toggle mode
8575        app.handle_key_event(make_key_event(KeyCode::Char('x')));
8576        assert!(!app.state.widget_toggle_mode);
8577    }
8578
8579    #[test]
8580    fn test_app_handle_key_all_keybindings() {
8581        let data = create_test_token_data();
8582        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8583
8584        // r = force refresh
8585        app.handle_key_event(make_key_event(KeyCode::Char('r')));
8586        assert!(!app.should_exit);
8587
8588        // Shift+P = toggle auto-pause
8589        let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
8590        app.handle_key_event(key);
8591        assert!(app.state.auto_pause_on_input);
8592        app.handle_key_event(key);
8593        assert!(!app.state.auto_pause_on_input);
8594
8595        // p = toggle pause
8596        app.handle_key_event(make_key_event(KeyCode::Char('p')));
8597        assert!(app.state.paused);
8598
8599        // space = toggle pause
8600        app.handle_key_event(make_key_event(KeyCode::Char(' ')));
8601        assert!(!app.state.paused);
8602
8603        // e = toggle export
8604        app.handle_key_event(make_key_event(KeyCode::Char('e')));
8605        assert!(app.state.export_active);
8606        // Stop export to avoid file handles
8607        app.state.stop_export();
8608
8609        // + = slower refresh
8610        let before_rate = app.state.refresh_rate;
8611        app.handle_key_event(make_key_event(KeyCode::Char('+')));
8612        assert!(app.state.refresh_rate >= before_rate);
8613
8614        // - = faster refresh
8615        let before_rate = app.state.refresh_rate;
8616        app.handle_key_event(make_key_event(KeyCode::Char('-')));
8617        assert!(app.state.refresh_rate <= before_rate);
8618
8619        // 1-6 = time periods
8620        app.handle_key_event(make_key_event(KeyCode::Char('1')));
8621        assert_eq!(app.state.time_period, TimePeriod::Min1);
8622        app.handle_key_event(make_key_event(KeyCode::Char('2')));
8623        assert_eq!(app.state.time_period, TimePeriod::Min5);
8624        app.handle_key_event(make_key_event(KeyCode::Char('3')));
8625        assert_eq!(app.state.time_period, TimePeriod::Min15);
8626        app.handle_key_event(make_key_event(KeyCode::Char('4')));
8627        assert_eq!(app.state.time_period, TimePeriod::Hour1);
8628        app.handle_key_event(make_key_event(KeyCode::Char('5')));
8629        assert_eq!(app.state.time_period, TimePeriod::Hour4);
8630        app.handle_key_event(make_key_event(KeyCode::Char('6')));
8631        assert_eq!(app.state.time_period, TimePeriod::Day1);
8632
8633        // t = cycle time period
8634        app.handle_key_event(make_key_event(KeyCode::Char('t')));
8635        assert_eq!(app.state.time_period, TimePeriod::Min1); // wraps from Day1
8636
8637        // c = toggle chart mode
8638        app.handle_key_event(make_key_event(KeyCode::Char('c')));
8639        assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
8640
8641        // s = toggle scale
8642        app.handle_key_event(make_key_event(KeyCode::Char('s')));
8643        assert_eq!(app.state.scale_mode, ScaleMode::Log);
8644
8645        // / = cycle color scheme
8646        app.handle_key_event(make_key_event(KeyCode::Char('/')));
8647        assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
8648
8649        // j = scroll log down
8650        app.handle_key_event(make_key_event(KeyCode::Char('j')));
8651
8652        // k = scroll log up
8653        app.handle_key_event(make_key_event(KeyCode::Char('k')));
8654
8655        // l = next layout
8656        app.handle_key_event(make_key_event(KeyCode::Char('l')));
8657        assert!(!app.state.auto_layout);
8658
8659        // h = prev layout
8660        app.handle_key_event(make_key_event(KeyCode::Char('h')));
8661
8662        // a = re-enable auto layout
8663        app.handle_key_event(make_key_event(KeyCode::Char('a')));
8664        assert!(app.state.auto_layout);
8665
8666        // w = widget toggle mode
8667        app.handle_key_event(make_key_event(KeyCode::Char('w')));
8668        assert!(app.state.widget_toggle_mode);
8669        // Cancel it
8670        app.handle_key_event(make_key_event(KeyCode::Char('z')));
8671
8672        // Unknown key is a no-op
8673        app.handle_key_event(make_key_event(KeyCode::F(12)));
8674        assert!(!app.should_exit);
8675    }
8676
8677    // ========================================================================
8678    // Integration tests: MonitorApp::fetch_data
8679    // ========================================================================
8680
8681    #[tokio::test]
8682    async fn test_app_fetch_data_success() {
8683        let data = create_test_token_data();
8684        let initial_price = data.price_usd;
8685        let mut updated = data.clone();
8686        updated.price_usd = 2.5;
8687        let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
8688
8689        assert!((app.state.current_price - initial_price).abs() < 0.001);
8690        app.fetch_data().await;
8691        assert!((app.state.current_price - 2.5).abs() < 0.001);
8692        assert!(app.state.error_message.is_none());
8693    }
8694
8695    #[tokio::test]
8696    async fn test_app_fetch_data_api_error() {
8697        let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
8698
8699        app.fetch_data().await;
8700        assert!(app.state.error_message.is_some());
8701        assert!(
8702            app.state
8703                .error_message
8704                .as_ref()
8705                .unwrap()
8706                .contains("API Error")
8707        );
8708    }
8709
8710    #[tokio::test]
8711    async fn test_app_fetch_data_holder_count_on_12th_tick() {
8712        let data = create_test_token_data();
8713        let mock_chain = MockChainClient::new(42_000);
8714        let mut app = create_test_app(
8715            Box::new(MockDexDataSource::new(data)),
8716            Some(Box::new(mock_chain)),
8717        );
8718
8719        // First 11 fetches should not update holder count
8720        for _ in 0..11 {
8721            app.fetch_data().await;
8722        }
8723        assert!(app.state.holder_count.is_none());
8724
8725        // 12th fetch triggers holder count lookup
8726        app.fetch_data().await;
8727        assert_eq!(app.state.holder_count, Some(42_000));
8728    }
8729
8730    #[tokio::test]
8731    async fn test_app_fetch_data_holder_count_zero_not_stored() {
8732        let data = create_test_token_data();
8733        let mock_chain = MockChainClient::new(0); // returns zero
8734        let mut app = create_test_app(
8735            Box::new(MockDexDataSource::new(data)),
8736            Some(Box::new(mock_chain)),
8737        );
8738
8739        // Skip to 12th tick
8740        app.state.holder_fetch_counter = 11;
8741        app.fetch_data().await;
8742        // Zero holder count should NOT be stored
8743        assert!(app.state.holder_count.is_none());
8744    }
8745
8746    #[tokio::test]
8747    async fn test_app_fetch_data_no_chain_client_skips_holders() {
8748        let data = create_test_token_data();
8749        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8750
8751        // Skip to 12th tick
8752        app.state.holder_fetch_counter = 11;
8753        app.fetch_data().await;
8754        // Without a chain client, holder count stays None
8755        assert!(app.state.holder_count.is_none());
8756    }
8757
8758    #[tokio::test]
8759    async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
8760        let data = create_test_token_data();
8761        let mock_chain = MockChainClient::new(42_000);
8762        let mut app = create_test_app(
8763            Box::new(MockDexDataSource::new(data)),
8764            Some(Box::new(mock_chain)),
8765        );
8766
8767        // Fetch holder count on 12th tick
8768        app.state.holder_fetch_counter = 11;
8769        app.fetch_data().await;
8770        assert_eq!(app.state.holder_count, Some(42_000));
8771
8772        // Replace chain client with one returning 0
8773        app.chain_client = Some(Box::new(MockChainClient::new(0)));
8774        // 24th tick
8775        app.state.holder_fetch_counter = 23;
8776        app.fetch_data().await;
8777        // Previous value should be preserved (zero is ignored)
8778        assert_eq!(app.state.holder_count, Some(42_000));
8779    }
8780
8781    // ========================================================================
8782    // Integration tests: MonitorApp::cleanup
8783    // ========================================================================
8784
8785    #[test]
8786    fn test_app_cleanup_does_not_panic_test_backend() {
8787        let data = create_test_token_data();
8788        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8789        // cleanup() with owns_terminal=false should not attempt terminal restore
8790        let result = app.cleanup();
8791        assert!(result.is_ok());
8792    }
8793
8794    // ========================================================================
8795    // Integration tests: MonitorApp draw renders without panic
8796    // ========================================================================
8797
8798    #[test]
8799    fn test_app_draw_renders_ui() {
8800        let data = create_test_token_data();
8801        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8802        // Verify we can render UI through the MonitorApp terminal
8803        app.terminal
8804            .draw(|f| ui(f, &mut app.state))
8805            .expect("should render without panic");
8806    }
8807
8808    // ========================================================================
8809    // Integration tests: select_token_impl
8810    // ========================================================================
8811
8812    fn make_search_results() -> Vec<TokenSearchResult> {
8813        vec![
8814            TokenSearchResult {
8815                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
8816                symbol: "USDC".to_string(),
8817                name: "USD Coin".to_string(),
8818                chain: "ethereum".to_string(),
8819                price_usd: Some(1.0),
8820                volume_24h: 5_000_000_000.0,
8821                liquidity_usd: 2_000_000_000.0,
8822                market_cap: Some(32_000_000_000.0),
8823            },
8824            TokenSearchResult {
8825                address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
8826                symbol: "USDT".to_string(),
8827                name: "Tether USD".to_string(),
8828                chain: "ethereum".to_string(),
8829                price_usd: Some(1.0),
8830                volume_24h: 6_000_000_000.0,
8831                liquidity_usd: 3_000_000_000.0,
8832                market_cap: Some(83_000_000_000.0),
8833            },
8834            TokenSearchResult {
8835                address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
8836                symbol: "DAI".to_string(),
8837                name: "Dai Stablecoin".to_string(),
8838                chain: "ethereum".to_string(),
8839                price_usd: Some(1.0),
8840                volume_24h: 200_000_000.0,
8841                liquidity_usd: 500_000_000.0,
8842                market_cap: Some(5_000_000_000.0),
8843            },
8844        ]
8845    }
8846
8847    #[test]
8848    fn test_select_token_impl_valid_first() {
8849        let results = make_search_results();
8850        let mut reader = io::Cursor::new(b"1\n");
8851        let mut writer = Vec::new();
8852        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8853        assert_eq!(selected.symbol, "USDC");
8854        assert_eq!(selected.address, results[0].address);
8855        let output = String::from_utf8(writer).unwrap();
8856        assert!(output.contains("Found 3 tokens"));
8857        assert!(output.contains("Selected: USDC"));
8858    }
8859
8860    #[test]
8861    fn test_select_token_impl_valid_last() {
8862        let results = make_search_results();
8863        let mut reader = io::Cursor::new(b"3\n");
8864        let mut writer = Vec::new();
8865        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8866        assert_eq!(selected.symbol, "DAI");
8867    }
8868
8869    #[test]
8870    fn test_select_token_impl_valid_middle() {
8871        let results = make_search_results();
8872        let mut reader = io::Cursor::new(b"2\n");
8873        let mut writer = Vec::new();
8874        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8875        assert_eq!(selected.symbol, "USDT");
8876    }
8877
8878    #[test]
8879    fn test_select_token_impl_out_of_bounds_zero() {
8880        let results = make_search_results();
8881        let mut reader = io::Cursor::new(b"0\n");
8882        let mut writer = Vec::new();
8883        let result = select_token_impl(&results, &mut reader, &mut writer);
8884        assert!(result.is_err());
8885        let err = result.unwrap_err().to_string();
8886        assert!(err.contains("Selection must be between 1 and 3"));
8887    }
8888
8889    #[test]
8890    fn test_select_token_impl_out_of_bounds_high() {
8891        let results = make_search_results();
8892        let mut reader = io::Cursor::new(b"99\n");
8893        let mut writer = Vec::new();
8894        let result = select_token_impl(&results, &mut reader, &mut writer);
8895        assert!(result.is_err());
8896    }
8897
8898    #[test]
8899    fn test_select_token_impl_non_numeric_input() {
8900        let results = make_search_results();
8901        let mut reader = io::Cursor::new(b"abc\n");
8902        let mut writer = Vec::new();
8903        let result = select_token_impl(&results, &mut reader, &mut writer);
8904        assert!(result.is_err());
8905        let err = result.unwrap_err().to_string();
8906        assert!(err.contains("Invalid selection"));
8907    }
8908
8909    #[test]
8910    fn test_select_token_impl_empty_input() {
8911        let results = make_search_results();
8912        let mut reader = io::Cursor::new(b"\n");
8913        let mut writer = Vec::new();
8914        let result = select_token_impl(&results, &mut reader, &mut writer);
8915        assert!(result.is_err());
8916    }
8917
8918    #[test]
8919    fn test_select_token_impl_long_name_truncation() {
8920        let results = vec![TokenSearchResult {
8921            address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
8922            symbol: "LONG".to_string(),
8923            name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
8924            chain: "ethereum".to_string(),
8925            price_usd: None,
8926            volume_24h: 100.0,
8927            liquidity_usd: 50.0,
8928            market_cap: None,
8929        }];
8930        let mut reader = io::Cursor::new(b"1\n");
8931        let mut writer = Vec::new();
8932        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8933        assert_eq!(selected.symbol, "LONG");
8934        let output = String::from_utf8(writer).unwrap();
8935        // Should have truncated name
8936        assert!(output.contains("A Very Long Token..."));
8937        // Should show N/A for price
8938        assert!(output.contains("N/A"));
8939    }
8940
8941    #[test]
8942    fn test_select_token_impl_output_format() {
8943        let results = make_search_results();
8944        let mut reader = io::Cursor::new(b"1\n");
8945        let mut writer = Vec::new();
8946        let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8947        let output = String::from_utf8(writer).unwrap();
8948
8949        // Verify table header
8950        assert!(output.contains("#"));
8951        assert!(output.contains("Symbol"));
8952        assert!(output.contains("Name"));
8953        assert!(output.contains("Address"));
8954        assert!(output.contains("Price"));
8955        assert!(output.contains("Liquidity"));
8956        // Verify separator line
8957        assert!(output.contains("─"));
8958        // Verify prompt
8959        assert!(output.contains("Select token (1-3):"));
8960    }
8961
8962    // ========================================================================
8963    // Integration tests: format_monitor_number
8964    // ========================================================================
8965
8966    #[test]
8967    fn test_format_monitor_number_billions() {
8968        assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
8969        assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
8970    }
8971
8972    #[test]
8973    fn test_format_monitor_number_millions() {
8974        assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
8975        assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
8976    }
8977
8978    #[test]
8979    fn test_format_monitor_number_thousands() {
8980        assert_eq!(format_monitor_number(5_000.0), "$5.00K");
8981        assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
8982    }
8983
8984    #[test]
8985    fn test_format_monitor_number_small() {
8986        assert_eq!(format_monitor_number(42.0), "$42.00");
8987        assert_eq!(format_monitor_number(0.5), "$0.50");
8988        assert_eq!(format_monitor_number(0.0), "$0.00");
8989    }
8990
8991    // ========================================================================
8992    // Integration tests: abbreviate_address edge cases
8993    // ========================================================================
8994
8995    #[test]
8996    fn test_abbreviate_address_exactly_16_chars() {
8997        let addr = "0123456789ABCDEF"; // exactly 16 chars
8998        assert_eq!(abbreviate_address(addr), addr);
8999    }
9000
9001    #[test]
9002    fn test_abbreviate_address_17_chars() {
9003        let addr = "0123456789ABCDEFG"; // 17 chars -> abbreviated
9004        assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
9005    }
9006
9007    // ========================================================================
9008    // Integration tests: MonitorApp with state + fetch combined scenario
9009    // ========================================================================
9010
9011    #[tokio::test]
9012    async fn test_app_full_scenario_fetch_render_quit() {
9013        let data = create_test_token_data();
9014        let mut updated = data.clone();
9015        updated.price_usd = 3.0;
9016        let mock_chain = MockChainClient::new(10_000);
9017        let state = MonitorState::new(&data, "ethereum");
9018        let mut app = create_test_app_with_state(
9019            state,
9020            Box::new(MockDexDataSource::new(updated)),
9021            Some(Box::new(mock_chain)),
9022        );
9023
9024        // 1. Fetch new data
9025        app.fetch_data().await;
9026        assert!((app.state.current_price - 3.0).abs() < 0.001);
9027
9028        // 2. Render UI
9029        app.terminal
9030            .draw(|f| ui(f, &mut app.state))
9031            .expect("render");
9032
9033        // 3. Start export
9034        app.handle_key_event(make_key_event(KeyCode::Char('e')));
9035        assert!(app.state.export_active);
9036
9037        // 4. Quit (should stop export)
9038        app.handle_key_event(make_key_event(KeyCode::Char('q')));
9039        assert!(app.should_exit);
9040        assert!(!app.state.export_active);
9041    }
9042
9043    #[tokio::test]
9044    async fn test_app_fetch_data_error_then_recovery() {
9045        let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
9046
9047        // First fetch fails
9048        app.fetch_data().await;
9049        assert!(app.state.error_message.is_some());
9050
9051        // Replace with working mock
9052        let mut recovered = create_test_token_data();
9053        recovered.price_usd = 5.0;
9054        app.dex_client = Box::new(MockDexDataSource::new(recovered));
9055
9056        // Second fetch succeeds
9057        app.fetch_data().await;
9058        assert!((app.state.current_price - 5.0).abs() < 0.001);
9059        // Error message is cleared by state.update()
9060    }
9061
9062    // ========================================================================
9063    // Integration tests: MonitorArgs parsing and run_direct config merging
9064    // ========================================================================
9065
9066    #[test]
9067    fn test_monitor_args_defaults() {
9068        use super::super::Cli;
9069        use clap::Parser;
9070        // Simulate: scope monitor USDC
9071        let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
9072        if let super::super::Commands::Monitor(args) = cli.command {
9073            assert_eq!(args.token, "USDC");
9074            assert_eq!(args.chain, "ethereum");
9075            assert!(args.layout.is_none());
9076            assert!(args.refresh.is_none());
9077            assert!(args.scale.is_none());
9078            assert!(args.color_scheme.is_none());
9079            assert!(args.export.is_none());
9080        } else {
9081            panic!("Expected Monitor command");
9082        }
9083    }
9084
9085    #[test]
9086    fn test_monitor_args_all_flags() {
9087        use super::super::Cli;
9088        use clap::Parser;
9089        let cli = Cli::try_parse_from([
9090            "scope",
9091            "monitor",
9092            "PEPE",
9093            "--chain",
9094            "solana",
9095            "--layout",
9096            "feed",
9097            "--refresh",
9098            "2",
9099            "--scale",
9100            "log",
9101            "--color-scheme",
9102            "monochrome",
9103            "--export",
9104            "/tmp/data.csv",
9105        ])
9106        .unwrap();
9107        if let super::super::Commands::Monitor(args) = cli.command {
9108            assert_eq!(args.token, "PEPE");
9109            assert_eq!(args.chain, "solana");
9110            assert_eq!(args.layout, Some(LayoutPreset::Feed));
9111            assert_eq!(args.refresh, Some(2));
9112            assert_eq!(args.scale, Some(ScaleMode::Log));
9113            assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
9114            assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
9115        } else {
9116            panic!("Expected Monitor command");
9117        }
9118    }
9119
9120    #[test]
9121    fn test_run_direct_config_override_layout() {
9122        // Verify that run_direct properly applies CLI overrides to config
9123        let config = Config::default();
9124        assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
9125
9126        let args = MonitorArgs {
9127            token: "USDC".to_string(),
9128            chain: "ethereum".to_string(),
9129            layout: Some(LayoutPreset::ChartFocus),
9130            refresh: None,
9131            scale: None,
9132            color_scheme: None,
9133            export: None,
9134            venue: None,
9135            pair: None,
9136        };
9137
9138        // Build the effective config the same way run_direct does
9139        let mut monitor_config = config.monitor.clone();
9140        if let Some(layout) = args.layout {
9141            monitor_config.layout = layout;
9142        }
9143        assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
9144    }
9145
9146    #[test]
9147    fn test_run_direct_config_override_all_fields() {
9148        let config = Config::default();
9149        let args = MonitorArgs {
9150            token: "PEPE".to_string(),
9151            chain: "solana".to_string(),
9152            layout: Some(LayoutPreset::Compact),
9153            refresh: Some(2),
9154            scale: Some(ScaleMode::Log),
9155            color_scheme: Some(ColorScheme::BlueOrange),
9156            export: Some(PathBuf::from("/tmp/test.csv")),
9157            venue: None,
9158            pair: None,
9159        };
9160
9161        let mut mc = config.monitor.clone();
9162        if let Some(layout) = args.layout {
9163            mc.layout = layout;
9164        }
9165        if let Some(refresh) = args.refresh {
9166            mc.refresh_seconds = refresh;
9167        }
9168        if let Some(scale) = args.scale {
9169            mc.scale = scale;
9170        }
9171        if let Some(color_scheme) = args.color_scheme {
9172            mc.color_scheme = color_scheme;
9173        }
9174        if let Some(ref path) = args.export {
9175            mc.export.path = Some(path.to_string_lossy().into_owned());
9176        }
9177
9178        assert_eq!(mc.layout, LayoutPreset::Compact);
9179        assert_eq!(mc.refresh_seconds, 2);
9180        assert_eq!(mc.scale, ScaleMode::Log);
9181        assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
9182        assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
9183    }
9184
9185    #[test]
9186    fn test_run_direct_config_no_overrides_preserves_defaults() {
9187        let config = Config::default();
9188        let args = MonitorArgs {
9189            token: "USDC".to_string(),
9190            chain: "ethereum".to_string(),
9191            layout: None,
9192            refresh: None,
9193            scale: None,
9194            color_scheme: None,
9195            export: None,
9196            venue: None,
9197            pair: None,
9198        };
9199
9200        let mut mc = config.monitor.clone();
9201        if let Some(layout) = args.layout {
9202            mc.layout = layout;
9203        }
9204        if let Some(refresh) = args.refresh {
9205            mc.refresh_seconds = refresh;
9206        }
9207        if let Some(scale) = args.scale {
9208            mc.scale = scale;
9209        }
9210        if let Some(color_scheme) = args.color_scheme {
9211            mc.color_scheme = color_scheme;
9212        }
9213
9214        // All should remain at defaults
9215        assert_eq!(mc.layout, LayoutPreset::Dashboard);
9216        assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
9217        assert_eq!(mc.scale, ScaleMode::Linear);
9218        assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
9219        assert!(mc.export.path.is_none());
9220    }
9221
9222    // =================================================================
9223    // OHLC / exchange_interval tests
9224    // =================================================================
9225
9226    #[test]
9227    fn test_exchange_interval_mapping() {
9228        assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
9229        assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
9230        assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
9231        assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
9232        assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
9233        assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
9234    }
9235
9236    #[test]
9237    fn test_monitor_state_exchange_ohlc_default_empty() {
9238        let token_data = create_test_token_data();
9239        let state = MonitorState::new(&token_data, "ethereum");
9240        assert!(state.exchange_ohlc.is_empty());
9241        assert!(state.venue_pair.is_none());
9242    }
9243
9244    #[test]
9245    fn test_get_ohlc_candles_prefers_exchange_data() {
9246        let token_data = create_test_token_data();
9247        let mut state = MonitorState::new(&token_data, "ethereum");
9248
9249        // Add some synthetic candles from price history
9250        let now = std::time::SystemTime::now()
9251            .duration_since(std::time::UNIX_EPOCH)
9252            .unwrap()
9253            .as_secs_f64();
9254        state.price_history.push_back(DataPoint {
9255            timestamp: now,
9256            value: 1.0,
9257            is_real: true,
9258        });
9259        state.price_history.push_back(DataPoint {
9260            timestamp: now + 5.0,
9261            value: 1.01,
9262            is_real: true,
9263        });
9264
9265        // Without exchange OHLC, should get synthetic candles
9266        let candles_before = state.get_ohlc_candles();
9267
9268        // Now add exchange OHLC data
9269        state.exchange_ohlc = vec![
9270            OhlcCandle {
9271                timestamp: 1700000000.0,
9272                open: 50000.0,
9273                high: 50500.0,
9274                low: 49800.0,
9275                close: 50200.0,
9276                is_bullish: true,
9277            },
9278            OhlcCandle {
9279                timestamp: 1700003600.0,
9280                open: 50200.0,
9281                high: 50800.0,
9282                low: 50100.0,
9283                close: 50700.0,
9284                is_bullish: true,
9285            },
9286        ];
9287
9288        let candles_after = state.get_ohlc_candles();
9289        assert_eq!(candles_after.len(), 2);
9290        assert_eq!(candles_after[0].open, 50000.0);
9291        assert_eq!(candles_after[1].close, 50700.0);
9292
9293        // Verify exchange data is preferred over synthetic
9294        if !candles_before.is_empty() {
9295            assert_ne!(candles_after[0].open, candles_before[0].open);
9296        }
9297    }
9298
9299    #[test]
9300    fn test_monitor_args_with_venue() {
9301        let args = MonitorArgs {
9302            token: "BTC".to_string(),
9303            chain: "ethereum".to_string(),
9304            refresh: None,
9305            layout: None,
9306            scale: None,
9307            color_scheme: None,
9308            export: None,
9309            venue: Some("binance".to_string()),
9310            pair: None,
9311        };
9312        assert_eq!(args.venue, Some("binance".to_string()));
9313    }
9314
9315    #[test]
9316    fn test_ohlc_candle_is_bullish_calculation() {
9317        let bullish = OhlcCandle {
9318            timestamp: 1700000000.0,
9319            open: 100.0,
9320            high: 110.0,
9321            low: 95.0,
9322            close: 105.0,
9323            is_bullish: true,
9324        };
9325        assert!(bullish.is_bullish);
9326
9327        let bearish = OhlcCandle {
9328            timestamp: 1700000000.0,
9329            open: 100.0,
9330            high: 105.0,
9331            low: 90.0,
9332            close: 95.0,
9333            is_bullish: false,
9334        };
9335        assert!(!bearish.is_bullish);
9336    }
9337
9338    #[test]
9339    fn test_build_exchange_token_data_from_ticker() {
9340        let ticker = crate::market::Ticker {
9341            pair: "PUSD/USDT".to_string(),
9342            last_price: Some(1.001),
9343            high_24h: Some(1.005),
9344            low_24h: Some(0.998),
9345            volume_24h: Some(500_000.0),
9346            quote_volume_24h: Some(500_500.0),
9347            best_bid: Some(1.0005),
9348            best_ask: Some(1.0015),
9349        };
9350
9351        let data = build_exchange_token_data("PUSD", "PUSD_USDT", &ticker);
9352
9353        assert_eq!(data.symbol, "PUSD");
9354        assert_eq!(data.name, "PUSD");
9355        assert_eq!(data.price_usd, 1.001);
9356        assert_eq!(data.volume_24h, 500_000.0);
9357        assert!(data.address.contains("exchange:"));
9358        assert!(data.pairs.is_empty());
9359        assert!(data.price_history.is_empty());
9360        assert!(data.dexscreener_url.is_none());
9361    }
9362
9363    #[test]
9364    fn test_build_exchange_token_data_missing_price() {
9365        let ticker = crate::market::Ticker {
9366            pair: "FOO/USDT".to_string(),
9367            last_price: None,
9368            high_24h: None,
9369            low_24h: None,
9370            volume_24h: None,
9371            quote_volume_24h: None,
9372            best_bid: None,
9373            best_ask: None,
9374        };
9375
9376        let data = build_exchange_token_data("FOO", "FOO_USDT", &ticker);
9377        assert_eq!(data.price_usd, 0.0);
9378        assert_eq!(data.volume_24h, 0.0);
9379    }
9380
9381    #[test]
9382    fn test_monitor_args_with_pair() {
9383        let args = MonitorArgs {
9384            token: "PUSD".to_string(),
9385            chain: "ethereum".to_string(),
9386            refresh: None,
9387            layout: None,
9388            scale: None,
9389            color_scheme: None,
9390            export: None,
9391            venue: Some("biconomy".to_string()),
9392            pair: Some("PUSD_USDT".to_string()),
9393        };
9394        assert_eq!(args.pair, Some("PUSD_USDT".to_string()));
9395        assert_eq!(args.venue, Some("biconomy".to_string()));
9396    }
9397
9398    #[test]
9399    fn test_monitor_args_pair_none_by_default() {
9400        let args = MonitorArgs {
9401            token: "BTC".to_string(),
9402            chain: "ethereum".to_string(),
9403            refresh: None,
9404            layout: None,
9405            scale: None,
9406            color_scheme: None,
9407            export: None,
9408            venue: None,
9409            pair: None,
9410        };
9411        assert!(args.pair.is_none());
9412    }
9413
9414    #[test]
9415    fn test_build_exchange_token_data_extracts_base_symbol() {
9416        let ticker = crate::market::Ticker {
9417            pair: "DOGE/USDT".to_string(),
9418            last_price: Some(0.123),
9419            high_24h: Some(0.13),
9420            low_24h: Some(0.12),
9421            volume_24h: Some(1_000_000.0),
9422            quote_volume_24h: Some(123_000.0),
9423            best_bid: Some(0.1225),
9424            best_ask: Some(0.1235),
9425        };
9426
9427        let data = build_exchange_token_data("DOGE", "DOGE_USDT", &ticker);
9428        assert_eq!(data.symbol, "DOGE");
9429        assert_eq!(data.name, "DOGE");
9430        assert_eq!(data.price_usd, 0.123);
9431        assert_eq!(data.volume_24h, 1_000_000.0);
9432        // Verify all DEX-specific fields are zeroed/empty
9433        assert_eq!(data.price_change_24h, 0.0);
9434        assert_eq!(data.price_change_6h, 0.0);
9435        assert_eq!(data.price_change_1h, 0.0);
9436        assert_eq!(data.price_change_5m, 0.0);
9437        assert_eq!(data.volume_6h, 0.0);
9438        assert_eq!(data.volume_1h, 0.0);
9439        assert_eq!(data.liquidity_usd, 0.0);
9440        assert!(data.market_cap.is_none());
9441        assert!(data.fdv.is_none());
9442        assert!(data.earliest_pair_created_at.is_none());
9443        assert!(data.image_url.is_none());
9444        assert!(data.websites.is_empty());
9445        assert!(data.socials.is_empty());
9446        assert_eq!(data.total_buys_24h, 0);
9447        assert_eq!(data.total_sells_24h, 0);
9448    }
9449
9450    #[test]
9451    fn test_build_exchange_token_data_address_format() {
9452        let ticker = crate::market::Ticker {
9453            pair: "X/Y".to_string(),
9454            last_price: Some(1.0),
9455            high_24h: None,
9456            low_24h: None,
9457            volume_24h: None,
9458            quote_volume_24h: None,
9459            best_bid: None,
9460            best_ask: None,
9461        };
9462
9463        let data = build_exchange_token_data("X", "X_Y", &ticker);
9464        assert_eq!(data.address, "exchange:X_Y");
9465    }
9466
9467    #[test]
9468    fn test_monitor_args_pair_requires_venue_conceptually() {
9469        // When --pair is set, --venue should also be set.
9470        // This is a structural test; the runtime validation is in run().
9471        let args = MonitorArgs {
9472            token: "PUSD".to_string(),
9473            chain: "ethereum".to_string(),
9474            refresh: None,
9475            layout: None,
9476            scale: None,
9477            color_scheme: None,
9478            export: None,
9479            venue: Some("biconomy".to_string()),
9480            pair: Some("PUSD_USDT".to_string()),
9481        };
9482        assert!(args.venue.is_some());
9483        assert!(args.pair.is_some());
9484    }
9485
9486    #[test]
9487    fn test_run_direct_config_pair_passthrough() {
9488        // Verify that run_direct properly propagates the pair field
9489        let config = Config::default();
9490        let args = MonitorArgs {
9491            token: "PUSD".to_string(),
9492            chain: "ethereum".to_string(),
9493            layout: None,
9494            refresh: None,
9495            scale: None,
9496            color_scheme: None,
9497            export: None,
9498            venue: Some("biconomy".to_string()),
9499            pair: Some("PUSD_USDT".to_string()),
9500        };
9501
9502        // Simulate the config override path from run_direct
9503        let mut mc = config.monitor.clone();
9504        if let Some(ref venue) = args.venue {
9505            mc.venue = Some(venue.clone());
9506        }
9507        assert_eq!(mc.venue, Some("biconomy".to_string()));
9508        // pair is passed directly to run(), not stored in MonitorConfig
9509        assert_eq!(args.pair, Some("PUSD_USDT".to_string()));
9510    }
9511
9512    // =========================================================================
9513    // resolve_token_address chain filter tests
9514    // =========================================================================
9515
9516    #[test]
9517    fn test_chain_filter_logic_ethereum_default() {
9518        // When chain is "ethereum" (default), chain_filter should be None
9519        // so we search ALL chains and exact matches on any chain sort first.
9520        let chain = "ethereum";
9521        let chain_filter: Option<&str> = if chain != "ethereum" {
9522            Some(chain)
9523        } else {
9524            None
9525        };
9526        assert!(chain_filter.is_none());
9527    }
9528
9529    #[test]
9530    fn test_chain_filter_logic_explicit_chain() {
9531        // When chain is explicitly set (not "ethereum"), filter to that chain.
9532        let chain = "solana";
9533        let chain_filter: Option<&str> = if chain != "ethereum" {
9534            Some(chain)
9535        } else {
9536            None
9537        };
9538        assert_eq!(chain_filter, Some("solana"));
9539    }
9540
9541    #[test]
9542    fn test_chain_filter_logic_bsc() {
9543        let chain = "bsc";
9544        let chain_filter: Option<&str> = if chain != "ethereum" {
9545            Some(chain)
9546        } else {
9547            None
9548        };
9549        assert_eq!(chain_filter, Some("bsc"));
9550    }
9551
9552    #[tokio::test]
9553    async fn test_resolve_token_address_with_address_input() {
9554        use crate::chains::dex::DexClient;
9555        let config = Config::default();
9556        let dex = DexClient::new();
9557        // EVM address should be returned directly, no DexScreener query
9558        let result = resolve_token_address(
9559            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
9560            "ethereum",
9561            &config,
9562            &dex,
9563        )
9564        .await
9565        .unwrap();
9566        assert_eq!(result, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
9567    }
9568
9569    #[tokio::test]
9570    async fn test_resolve_token_address_solana_address() {
9571        use crate::chains::dex::DexClient;
9572        let config = Config::default();
9573        let dex = DexClient::new();
9574        // Solana address (base58, 32+ chars) should be returned directly
9575        let addr = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
9576        let result = resolve_token_address(addr, "solana", &config, &dex)
9577            .await
9578            .unwrap();
9579        assert_eq!(result, addr);
9580    }
9581
9582    #[test]
9583    fn test_try_cex_fallback_returns_correct_structure() {
9584        // Verify the fallback constructs results with the right shape
9585        // (can't easily test async CEX call without mocking, so test the structure)
9586        let result = crate::chains::TokenSearchResult {
9587            address: String::new(),
9588            symbol: "TEST".to_string(),
9589            name: "TEST".to_string(),
9590            chain: "ethereum".to_string(),
9591            price_usd: Some(1.0),
9592            volume_24h: 100.0,
9593            liquidity_usd: 0.0,
9594            market_cap: None,
9595        };
9596        assert_eq!(result.symbol, "TEST");
9597        assert!(result.address.is_empty());
9598        assert_eq!(result.liquidity_usd, 0.0);
9599    }
9600}