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(
4331    symbol: &str,
4332    chain: &str,
4333) -> Option<crate::chains::TokenSearchResult> {
4334    let registry = crate::market::VenueRegistry::load().ok()?;
4335    let descriptor = registry.get("binance")?;
4336    let client = crate::market::ExchangeClient::from_descriptor(&descriptor.clone());
4337    let pair = client.format_pair(&format!("{}USDT", symbol.to_uppercase()));
4338    let ticker = client.fetch_ticker(&pair).await.ok()?;
4339    let price = ticker.last_price.unwrap_or(0.0);
4340    Some(crate::chains::TokenSearchResult {
4341        address: String::new(),
4342        symbol: symbol.to_uppercase(),
4343        name: symbol.to_uppercase(),
4344        chain: chain.to_string(),
4345        price_usd: Some(price),
4346        volume_24h: ticker.volume_24h.unwrap_or(0.0),
4347        liquidity_usd: 0.0,
4348        market_cap: None,
4349    })
4350}
4351
4352/// Abbreviates a blockchain address for display (e.g. "0x1234...abcd").
4353fn abbreviate_address(addr: &str) -> String {
4354    if addr.len() > 16 {
4355        format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
4356    } else {
4357        addr.to_string()
4358    }
4359}
4360
4361/// Displays token search results and prompts the user to select one.
4362fn select_token_interactive(
4363    results: &[crate::chains::dex::TokenSearchResult],
4364) -> Result<&crate::chains::dex::TokenSearchResult> {
4365    let stdin = io::stdin();
4366    let stdout = io::stdout();
4367    select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
4368}
4369
4370/// Testable implementation of token selection with injected I/O.
4371fn select_token_impl<'a>(
4372    results: &'a [crate::chains::dex::TokenSearchResult],
4373    reader: &mut impl io::BufRead,
4374    writer: &mut impl io::Write,
4375) -> Result<&'a crate::chains::dex::TokenSearchResult> {
4376    writeln!(
4377        writer,
4378        "\nFound {} tokens matching your query:\n",
4379        results.len()
4380    )
4381    .map_err(|e| ScopeError::Io(e.to_string()))?;
4382
4383    writeln!(
4384        writer,
4385        "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
4386        "#", "Symbol", "Name", "Address", "Price", "Liquidity"
4387    )
4388    .map_err(|e| ScopeError::Io(e.to_string()))?;
4389
4390    writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
4391
4392    for (i, token) in results.iter().enumerate() {
4393        let price = token
4394            .price_usd
4395            .map(|p| format!("${:.6}", p))
4396            .unwrap_or_else(|| "N/A".to_string());
4397
4398        let liquidity = format_monitor_number(token.liquidity_usd);
4399        let addr = abbreviate_address(&token.address);
4400
4401        // Truncate name if too long
4402        let name = if token.name.len() > 20 {
4403            format!("{}...", &token.name[..17])
4404        } else {
4405            token.name.clone()
4406        };
4407
4408        writeln!(
4409            writer,
4410            "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
4411            i + 1,
4412            token.symbol,
4413            name,
4414            addr,
4415            price,
4416            liquidity
4417        )
4418        .map_err(|e| ScopeError::Io(e.to_string()))?;
4419    }
4420
4421    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
4422    write!(writer, "Select token (1-{}): ", results.len())
4423        .map_err(|e| ScopeError::Io(e.to_string()))?;
4424    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
4425
4426    let mut input = String::new();
4427    reader
4428        .read_line(&mut input)
4429        .map_err(|e| ScopeError::Io(e.to_string()))?;
4430
4431    let selection: usize = input
4432        .trim()
4433        .parse()
4434        .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
4435
4436    if selection < 1 || selection > results.len() {
4437        return Err(ScopeError::Api(format!(
4438            "Selection must be between 1 and {}",
4439            results.len()
4440        )));
4441    }
4442
4443    let selected = &results[selection - 1];
4444    writeln!(
4445        writer,
4446        "Selected: {} ({}) at {}",
4447        selected.symbol, selected.name, selected.address
4448    )
4449    .map_err(|e| ScopeError::Io(e.to_string()))?;
4450
4451    Ok(selected)
4452}
4453
4454/// Format a number for the monitor selection table.
4455fn format_monitor_number(value: f64) -> String {
4456    if value >= 1_000_000_000.0 {
4457        format!("${:.2}B", value / 1_000_000_000.0)
4458    } else if value >= 1_000_000.0 {
4459        format!("${:.2}M", value / 1_000_000.0)
4460    } else if value >= 1_000.0 {
4461        format!("${:.2}K", value / 1_000.0)
4462    } else {
4463        format!("${:.2}", value)
4464    }
4465}
4466
4467// ============================================================================
4468// Unit Tests
4469// ============================================================================
4470
4471#[cfg(test)]
4472mod tests {
4473    use super::*;
4474
4475    fn create_test_token_data() -> DexTokenData {
4476        DexTokenData {
4477            address: "0x1234".to_string(),
4478            symbol: "TEST".to_string(),
4479            name: "Test Token".to_string(),
4480            price_usd: 1.0,
4481            price_change_24h: 5.0,
4482            price_change_6h: 2.0,
4483            price_change_1h: 0.5,
4484            price_change_5m: 0.1,
4485            volume_24h: 1_000_000.0,
4486            volume_6h: 250_000.0,
4487            volume_1h: 50_000.0,
4488            liquidity_usd: 500_000.0,
4489            market_cap: Some(10_000_000.0),
4490            fdv: Some(100_000_000.0),
4491            pairs: vec![],
4492            price_history: vec![],
4493            volume_history: vec![],
4494            total_buys_24h: 100,
4495            total_sells_24h: 50,
4496            total_buys_6h: 25,
4497            total_sells_6h: 12,
4498            total_buys_1h: 5,
4499            total_sells_1h: 3,
4500            earliest_pair_created_at: Some(1700000000000),
4501            image_url: None,
4502            websites: vec![],
4503            socials: vec![],
4504            dexscreener_url: None,
4505        }
4506    }
4507
4508    #[test]
4509    fn test_monitor_state_new() {
4510        let token_data = create_test_token_data();
4511        let state = MonitorState::new(&token_data, "ethereum");
4512
4513        assert_eq!(state.symbol, "TEST");
4514        assert_eq!(state.chain, "ethereum");
4515        assert_eq!(state.current_price, 1.0);
4516        assert_eq!(state.buys_24h, 100);
4517        assert_eq!(state.sells_24h, 50);
4518        assert!(!state.paused);
4519    }
4520
4521    #[test]
4522    fn test_monitor_state_buy_ratio() {
4523        let token_data = create_test_token_data();
4524        let state = MonitorState::new(&token_data, "ethereum");
4525
4526        let ratio = state.buy_ratio();
4527        assert!((ratio - 0.6666).abs() < 0.01); // 100 / 150 ≈ 0.667
4528    }
4529
4530    #[test]
4531    fn test_monitor_state_buy_ratio_zero() {
4532        let mut token_data = create_test_token_data();
4533        token_data.total_buys_24h = 0;
4534        token_data.total_sells_24h = 0;
4535        let state = MonitorState::new(&token_data, "ethereum");
4536
4537        assert_eq!(state.buy_ratio(), 0.5); // Default to 50/50 when no data
4538    }
4539
4540    #[test]
4541    fn test_monitor_state_toggle_pause() {
4542        let token_data = create_test_token_data();
4543        let mut state = MonitorState::new(&token_data, "ethereum");
4544
4545        assert!(!state.paused);
4546        state.toggle_pause();
4547        assert!(state.paused);
4548        state.toggle_pause();
4549        assert!(!state.paused);
4550    }
4551
4552    #[test]
4553    fn test_monitor_state_should_refresh() {
4554        let token_data = create_test_token_data();
4555        let mut state = MonitorState::new(&token_data, "ethereum");
4556        state.refresh_rate = Duration::from_secs(60);
4557
4558        // Just created, should not need refresh (60s refresh rate)
4559        assert!(!state.should_refresh());
4560
4561        // Simulate time passing well beyond refresh rate
4562        state.last_update = Instant::now() - Duration::from_secs(120);
4563        assert!(state.should_refresh());
4564
4565        // Pause should prevent refresh
4566        state.paused = true;
4567        assert!(!state.should_refresh());
4568    }
4569
4570    #[test]
4571    fn test_format_number() {
4572        assert_eq!(format_number(500.0), "500.00");
4573        assert_eq!(format_number(1_500.0), "1.50K");
4574        assert_eq!(format_number(1_500_000.0), "1.50M");
4575        assert_eq!(format_number(1_500_000_000.0), "1.50B");
4576    }
4577
4578    #[test]
4579    fn test_format_usd() {
4580        assert_eq!(crate::display::format_usd(500.0), "$500.00");
4581        assert_eq!(crate::display::format_usd(1_500.0), "$1.50K");
4582        assert_eq!(crate::display::format_usd(1_500_000.0), "$1.50M");
4583        assert_eq!(crate::display::format_usd(1_500_000_000.0), "$1.50B");
4584    }
4585
4586    #[test]
4587    fn test_monitor_state_update() {
4588        let token_data = create_test_token_data();
4589        let mut state = MonitorState::new(&token_data, "ethereum");
4590
4591        let initial_len = state.price_history.len();
4592
4593        let mut updated_data = token_data.clone();
4594        updated_data.price_usd = 1.5;
4595        updated_data.total_buys_24h = 150;
4596
4597        state.update(&updated_data);
4598
4599        assert_eq!(state.current_price, 1.5);
4600        assert_eq!(state.buys_24h, 150);
4601        // Should have one more point after update
4602        assert_eq!(state.price_history.len(), initial_len + 1);
4603    }
4604
4605    #[test]
4606    fn test_monitor_state_refresh_rate_adjustment() {
4607        let token_data = create_test_token_data();
4608        let mut state = MonitorState::new(&token_data, "ethereum");
4609
4610        // Default is 5 seconds
4611        assert_eq!(state.refresh_rate_secs(), 5);
4612
4613        // Slow down (+5s)
4614        state.slower_refresh();
4615        assert_eq!(state.refresh_rate_secs(), 10);
4616
4617        // Speed up (-5s)
4618        state.faster_refresh();
4619        assert_eq!(state.refresh_rate_secs(), 5);
4620
4621        // Speed up again (should hit minimum of 1s)
4622        state.faster_refresh();
4623        assert_eq!(state.refresh_rate_secs(), 1);
4624
4625        // Can't go below 1s
4626        state.faster_refresh();
4627        assert_eq!(state.refresh_rate_secs(), 1);
4628
4629        // Slow down to max (60s)
4630        for _ in 0..20 {
4631            state.slower_refresh();
4632        }
4633        assert_eq!(state.refresh_rate_secs(), 60);
4634    }
4635
4636    #[test]
4637    fn test_time_period() {
4638        assert_eq!(TimePeriod::Min1.label(), "1m");
4639        assert_eq!(TimePeriod::Min5.label(), "5m");
4640        assert_eq!(TimePeriod::Min15.label(), "15m");
4641        assert_eq!(TimePeriod::Hour1.label(), "1h");
4642        assert_eq!(TimePeriod::Hour4.label(), "4h");
4643        assert_eq!(TimePeriod::Day1.label(), "1d");
4644
4645        assert_eq!(TimePeriod::Min1.duration_secs(), 60);
4646        assert_eq!(TimePeriod::Min5.duration_secs(), 300);
4647        assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
4648        assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
4649        assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
4650        assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
4651
4652        // Test cycling
4653        assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
4654        assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
4655        assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
4656        assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
4657        assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
4658        assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
4659    }
4660
4661    #[test]
4662    fn test_time_period_exchange_interval() {
4663        assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
4664        assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
4665        assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
4666        assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
4667        assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
4668        assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
4669    }
4670
4671    #[test]
4672    fn test_monitor_state_time_period() {
4673        let token_data = create_test_token_data();
4674        let mut state = MonitorState::new(&token_data, "ethereum");
4675
4676        // Default is 1 hour
4677        assert_eq!(state.time_period, TimePeriod::Hour1);
4678
4679        // Cycle through periods
4680        state.cycle_time_period();
4681        assert_eq!(state.time_period, TimePeriod::Hour4);
4682
4683        state.set_time_period(TimePeriod::Day1);
4684        assert_eq!(state.time_period, TimePeriod::Day1);
4685    }
4686
4687    #[test]
4688    fn test_synthetic_history_generation() {
4689        let token_data = create_test_token_data();
4690        let state = MonitorState::new(&token_data, "ethereum");
4691
4692        // Should have generated history (synthetic or cached real)
4693        assert!(state.price_history.len() > 1);
4694        assert!(state.volume_history.len() > 1);
4695
4696        // Price history should span some time range
4697        if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
4698        {
4699            let span = last.timestamp - first.timestamp;
4700            assert!(span > 0.0); // History should span some time
4701        }
4702    }
4703
4704    #[test]
4705    fn test_real_data_marking() {
4706        let token_data = create_test_token_data();
4707        let mut state = MonitorState::new(&token_data, "ethereum");
4708
4709        // Initially all synthetic
4710        let (synthetic, real) = state.data_stats();
4711        assert!(synthetic > 0);
4712        assert_eq!(real, 0);
4713
4714        // After update, should have real data
4715        let mut updated_data = token_data.clone();
4716        updated_data.price_usd = 1.5;
4717        state.update(&updated_data);
4718
4719        let (synthetic2, real2) = state.data_stats();
4720        assert!(synthetic2 > 0);
4721        assert_eq!(real2, 1);
4722        assert_eq!(state.real_data_count, 1);
4723
4724        // The last point should be real
4725        assert!(
4726            state
4727                .price_history
4728                .back()
4729                .map(|p| p.is_real)
4730                .unwrap_or(false)
4731        );
4732    }
4733
4734    #[test]
4735    fn test_memory_usage() {
4736        let token_data = create_test_token_data();
4737        let state = MonitorState::new(&token_data, "ethereum");
4738
4739        let mem = state.memory_usage();
4740        // DataPoint is 24 bytes, should have some data points
4741        assert!(mem > 0);
4742
4743        // Each DataPoint is 24 bytes (f64 + f64 + bool + padding)
4744        let expected_point_size = std::mem::size_of::<DataPoint>();
4745        assert_eq!(expected_point_size, 24);
4746    }
4747
4748    #[test]
4749    fn test_get_data_for_period_returns_flags() {
4750        let token_data = create_test_token_data();
4751        let mut state = MonitorState::new(&token_data, "ethereum");
4752
4753        // Get initial data (may contain cached real data or synthetic)
4754        let (data, is_real) = state.get_price_data_for_period();
4755        assert_eq!(data.len(), is_real.len());
4756
4757        // Add real data point
4758        state.update(&token_data);
4759
4760        let (_data2, is_real2) = state.get_price_data_for_period();
4761        // Should have at least one real point now
4762        assert!(is_real2.iter().any(|r| *r));
4763    }
4764
4765    #[test]
4766    fn test_cache_path_generation() {
4767        let path =
4768            MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
4769        assert!(path.to_string_lossy().contains("bcc_monitor_"));
4770        assert!(path.to_string_lossy().contains("ethereum"));
4771        // Should be in temp directory
4772        let temp_dir = std::env::temp_dir();
4773        assert!(path.starts_with(temp_dir));
4774    }
4775
4776    #[test]
4777    fn test_cache_save_and_load() {
4778        let token_data = create_test_token_data();
4779        let mut state = MonitorState::new(&token_data, "test_chain");
4780
4781        // Add some real data
4782        state.update(&token_data);
4783        state.update(&token_data);
4784
4785        // Save cache
4786        state.save_cache();
4787
4788        // Verify cache file exists
4789        let path = MonitorState::cache_path(&state.token_address, &state.chain);
4790        assert!(path.exists(), "Cache file should exist after save");
4791
4792        // Load cache
4793        let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
4794        assert!(loaded.is_some(), "Should be able to load saved cache");
4795
4796        let cached = loaded.unwrap();
4797        assert_eq!(cached.token_address, state.token_address);
4798        assert_eq!(cached.chain, state.chain);
4799        assert!(!cached.price_history.is_empty());
4800
4801        // Cleanup
4802        let _ = std::fs::remove_file(path);
4803    }
4804
4805    // ========================================================================
4806    // Price formatting tests
4807    // ========================================================================
4808
4809    #[test]
4810    fn test_format_price_usd_high() {
4811        let formatted = format_price_usd(2500.50);
4812        assert!(formatted.starts_with("$2500.50"));
4813    }
4814
4815    #[test]
4816    fn test_format_price_usd_stablecoin() {
4817        let formatted = format_price_usd(1.0001);
4818        assert!(formatted.contains("1.000100"));
4819        assert!(is_stablecoin_price(1.0001));
4820    }
4821
4822    #[test]
4823    fn test_format_price_usd_medium() {
4824        let formatted = format_price_usd(5.1234);
4825        assert!(formatted.starts_with("$5.1234"));
4826    }
4827
4828    #[test]
4829    fn test_format_price_usd_small() {
4830        let formatted = format_price_usd(0.05);
4831        assert!(formatted.starts_with("$0.0500"));
4832    }
4833
4834    #[test]
4835    fn test_format_price_usd_micro() {
4836        let formatted = format_price_usd(0.001);
4837        assert!(formatted.starts_with("$0.0010"));
4838    }
4839
4840    #[test]
4841    fn test_format_price_usd_nano() {
4842        let formatted = format_price_usd(0.00001);
4843        assert!(formatted.contains("0.0000100"));
4844    }
4845
4846    #[test]
4847    fn test_is_stablecoin_price() {
4848        assert!(is_stablecoin_price(1.0));
4849        assert!(is_stablecoin_price(0.999));
4850        assert!(is_stablecoin_price(1.001));
4851        assert!(is_stablecoin_price(0.95));
4852        assert!(is_stablecoin_price(1.05));
4853        assert!(!is_stablecoin_price(0.94));
4854        assert!(!is_stablecoin_price(1.06));
4855        assert!(!is_stablecoin_price(100.0));
4856    }
4857
4858    // ========================================================================
4859    // OHLC candle tests
4860    // ========================================================================
4861
4862    #[test]
4863    fn test_ohlc_candle_new() {
4864        let candle = OhlcCandle::new(1000.0, 50.0);
4865        assert_eq!(candle.open, 50.0);
4866        assert_eq!(candle.high, 50.0);
4867        assert_eq!(candle.low, 50.0);
4868        assert_eq!(candle.close, 50.0);
4869        assert!(candle.is_bullish);
4870    }
4871
4872    #[test]
4873    fn test_ohlc_candle_update() {
4874        let mut candle = OhlcCandle::new(1000.0, 50.0);
4875        candle.update(55.0);
4876        assert_eq!(candle.high, 55.0);
4877        assert_eq!(candle.close, 55.0);
4878        assert!(candle.is_bullish);
4879
4880        candle.update(45.0);
4881        assert_eq!(candle.low, 45.0);
4882        assert_eq!(candle.close, 45.0);
4883        assert!(!candle.is_bullish); // close < open
4884    }
4885
4886    #[test]
4887    fn test_get_ohlc_candles() {
4888        let token_data = create_test_token_data();
4889        let mut state = MonitorState::new(&token_data, "ethereum");
4890        // Add several data points
4891        for i in 0..20 {
4892            let mut data = token_data.clone();
4893            data.price_usd = 1.0 + (i as f64 * 0.01);
4894            state.update(&data);
4895        }
4896        let candles = state.get_ohlc_candles();
4897        // Should have some candles
4898        assert!(!candles.is_empty());
4899    }
4900
4901    #[test]
4902    fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
4903        let token_data = create_test_token_data();
4904        let mut state = MonitorState::new(&token_data, "ethereum");
4905        // Populate exchange OHLC
4906        let exchange_candles = vec![
4907            OhlcCandle::new(1700000000.0, 100.0),
4908            OhlcCandle::new(1700003600.0, 101.0),
4909        ];
4910        state.exchange_ohlc = exchange_candles.clone();
4911        let candles = state.get_ohlc_candles();
4912        assert_eq!(candles.len(), 2);
4913        assert_eq!(candles[0].timestamp, 1700000000.0);
4914        assert_eq!(candles[0].open, 100.0);
4915        assert_eq!(candles[1].timestamp, 1700003600.0);
4916        assert_eq!(candles[1].open, 101.0);
4917    }
4918
4919    // ========================================================================
4920    // ChartMode tests
4921    // ========================================================================
4922
4923    #[test]
4924    fn test_chart_mode_cycle() {
4925        let mode = ChartMode::Line;
4926        assert_eq!(mode.next(), ChartMode::Candlestick);
4927        assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
4928        assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
4929    }
4930
4931    #[test]
4932    fn test_chart_mode_label() {
4933        assert_eq!(ChartMode::Line.label(), "Line");
4934        assert_eq!(ChartMode::Candlestick.label(), "Candle");
4935        assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
4936    }
4937
4938    // ========================================================================
4939    // TUI rendering tests (headless TestBackend)
4940    // ========================================================================
4941
4942    use ratatui::Terminal;
4943    use ratatui::backend::TestBackend;
4944
4945    fn create_test_terminal() -> Terminal<TestBackend> {
4946        let backend = TestBackend::new(120, 40);
4947        Terminal::new(backend).unwrap()
4948    }
4949
4950    fn create_populated_state() -> MonitorState {
4951        let token_data = create_test_token_data();
4952        let mut state = MonitorState::new(&token_data, "ethereum");
4953        // Add real data points so charts have content
4954        for i in 0..30 {
4955            let mut data = token_data.clone();
4956            data.price_usd = 1.0 + (i as f64 * 0.01);
4957            data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
4958            state.update(&data);
4959        }
4960        state
4961    }
4962
4963    #[test]
4964    fn test_render_header_no_panic() {
4965        let mut terminal = create_test_terminal();
4966        let state = create_populated_state();
4967        terminal
4968            .draw(|f| render_header(f, f.area(), &state))
4969            .unwrap();
4970    }
4971
4972    #[test]
4973    fn test_render_price_chart_no_panic() {
4974        let mut terminal = create_test_terminal();
4975        let state = create_populated_state();
4976        terminal
4977            .draw(|f| render_price_chart(f, f.area(), &state))
4978            .unwrap();
4979    }
4980
4981    #[test]
4982    fn test_render_price_chart_line_mode() {
4983        let mut terminal = create_test_terminal();
4984        let mut state = create_populated_state();
4985        state.chart_mode = ChartMode::Line;
4986        terminal
4987            .draw(|f| render_price_chart(f, f.area(), &state))
4988            .unwrap();
4989    }
4990
4991    #[test]
4992    fn test_render_candlestick_chart_no_panic() {
4993        let mut terminal = create_test_terminal();
4994        let state = create_populated_state();
4995        terminal
4996            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4997            .unwrap();
4998    }
4999
5000    #[test]
5001    fn test_render_candlestick_chart_empty() {
5002        let mut terminal = create_test_terminal();
5003        let token_data = create_test_token_data();
5004        let state = MonitorState::new(&token_data, "ethereum");
5005        terminal
5006            .draw(|f| render_candlestick_chart(f, f.area(), &state))
5007            .unwrap();
5008    }
5009
5010    #[test]
5011    fn test_render_volume_chart_no_panic() {
5012        let mut terminal = create_test_terminal();
5013        let state = create_populated_state();
5014        terminal
5015            .draw(|f| render_volume_chart(f, f.area(), &state))
5016            .unwrap();
5017    }
5018
5019    #[test]
5020    fn test_render_volume_chart_empty() {
5021        let mut terminal = create_test_terminal();
5022        let token_data = create_test_token_data();
5023        let state = MonitorState::new(&token_data, "ethereum");
5024        terminal
5025            .draw(|f| render_volume_chart(f, f.area(), &state))
5026            .unwrap();
5027    }
5028
5029    #[test]
5030    fn test_render_buy_sell_gauge_no_panic() {
5031        let mut terminal = create_test_terminal();
5032        let mut state = create_populated_state();
5033        terminal
5034            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5035            .unwrap();
5036    }
5037
5038    #[test]
5039    fn test_render_buy_sell_gauge_balanced() {
5040        let mut terminal = create_test_terminal();
5041        let mut token_data = create_test_token_data();
5042        token_data.total_buys_24h = 100;
5043        token_data.total_sells_24h = 100;
5044        let mut state = MonitorState::new(&token_data, "ethereum");
5045        terminal
5046            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5047            .unwrap();
5048    }
5049
5050    #[test]
5051    fn test_render_metrics_panel_no_panic() {
5052        let mut terminal = create_test_terminal();
5053        let state = create_populated_state();
5054        terminal
5055            .draw(|f| render_metrics_panel(f, f.area(), &state))
5056            .unwrap();
5057    }
5058
5059    #[test]
5060    fn test_render_metrics_panel_no_market_cap() {
5061        let mut terminal = create_test_terminal();
5062        let mut token_data = create_test_token_data();
5063        token_data.market_cap = None;
5064        token_data.fdv = None;
5065        let state = MonitorState::new(&token_data, "ethereum");
5066        terminal
5067            .draw(|f| render_metrics_panel(f, f.area(), &state))
5068            .unwrap();
5069    }
5070
5071    #[test]
5072    fn test_render_footer_no_panic() {
5073        let mut terminal = create_test_terminal();
5074        let state = create_populated_state();
5075        terminal
5076            .draw(|f| render_footer(f, f.area(), &state))
5077            .unwrap();
5078    }
5079
5080    #[test]
5081    fn test_render_footer_paused() {
5082        let mut terminal = create_test_terminal();
5083        let token_data = create_test_token_data();
5084        let mut state = MonitorState::new(&token_data, "ethereum");
5085        state.paused = true;
5086        terminal
5087            .draw(|f| render_footer(f, f.area(), &state))
5088            .unwrap();
5089    }
5090
5091    #[test]
5092    fn test_render_all_components() {
5093        // Exercise the full draw_ui layout path
5094        let mut terminal = create_test_terminal();
5095        let mut state = create_populated_state();
5096        terminal
5097            .draw(|f| {
5098                let area = f.area();
5099                let chunks = Layout::default()
5100                    .direction(Direction::Vertical)
5101                    .constraints([
5102                        Constraint::Length(3),
5103                        Constraint::Min(10),
5104                        Constraint::Length(5),
5105                        Constraint::Length(3),
5106                        Constraint::Length(3),
5107                    ])
5108                    .split(area);
5109                render_header(f, chunks[0], &state);
5110                render_price_chart(f, chunks[1], &state);
5111                render_volume_chart(f, chunks[2], &state);
5112                render_buy_sell_gauge(f, chunks[3], &mut state);
5113                render_footer(f, chunks[4], &state);
5114            })
5115            .unwrap();
5116    }
5117
5118    #[test]
5119    fn test_render_candlestick_mode() {
5120        let mut terminal = create_test_terminal();
5121        let mut state = create_populated_state();
5122        state.chart_mode = ChartMode::Candlestick;
5123        terminal
5124            .draw(|f| {
5125                let area = f.area();
5126                let chunks = Layout::default()
5127                    .direction(Direction::Vertical)
5128                    .constraints([Constraint::Length(3), Constraint::Min(10)])
5129                    .split(area);
5130                render_header(f, chunks[0], &state);
5131                render_candlestick_chart(f, chunks[1], &state);
5132            })
5133            .unwrap();
5134    }
5135
5136    #[test]
5137    fn test_render_with_different_time_periods() {
5138        let mut terminal = create_test_terminal();
5139        let mut state = create_populated_state();
5140
5141        for period in [
5142            TimePeriod::Min1,
5143            TimePeriod::Min5,
5144            TimePeriod::Min15,
5145            TimePeriod::Hour1,
5146            TimePeriod::Hour4,
5147            TimePeriod::Day1,
5148        ] {
5149            state.time_period = period;
5150            terminal
5151                .draw(|f| render_price_chart(f, f.area(), &state))
5152                .unwrap();
5153        }
5154    }
5155
5156    #[test]
5157    fn test_render_metrics_with_stablecoin() {
5158        let mut terminal = create_test_terminal();
5159        let mut token_data = create_test_token_data();
5160        token_data.price_usd = 0.999;
5161        token_data.symbol = "USDC".to_string();
5162        let state = MonitorState::new(&token_data, "ethereum");
5163        terminal
5164            .draw(|f| render_metrics_panel(f, f.area(), &state))
5165            .unwrap();
5166    }
5167
5168    #[test]
5169    fn test_render_header_with_negative_change() {
5170        let mut terminal = create_test_terminal();
5171        let mut token_data = create_test_token_data();
5172        token_data.price_change_24h = -15.5;
5173        token_data.price_change_1h = -2.3;
5174        let state = MonitorState::new(&token_data, "ethereum");
5175        terminal
5176            .draw(|f| render_header(f, f.area(), &state))
5177            .unwrap();
5178    }
5179
5180    // ========================================================================
5181    // MonitorState method tests
5182    // ========================================================================
5183
5184    #[test]
5185    fn test_toggle_chart_mode_roundtrip() {
5186        let token_data = create_test_token_data();
5187        let mut state = MonitorState::new(&token_data, "ethereum");
5188        assert_eq!(state.chart_mode, ChartMode::Line);
5189        state.toggle_chart_mode();
5190        assert_eq!(state.chart_mode, ChartMode::Candlestick);
5191        state.toggle_chart_mode();
5192        assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
5193        state.toggle_chart_mode();
5194        assert_eq!(state.chart_mode, ChartMode::Line);
5195    }
5196
5197    #[test]
5198    fn test_cycle_all_time_periods() {
5199        let token_data = create_test_token_data();
5200        let mut state = MonitorState::new(&token_data, "ethereum");
5201        assert_eq!(state.time_period, TimePeriod::Hour1);
5202        state.cycle_time_period();
5203        assert_eq!(state.time_period, TimePeriod::Hour4);
5204        state.cycle_time_period();
5205        assert_eq!(state.time_period, TimePeriod::Day1);
5206        state.cycle_time_period();
5207        assert_eq!(state.time_period, TimePeriod::Min1);
5208        state.cycle_time_period();
5209        assert_eq!(state.time_period, TimePeriod::Min5);
5210        state.cycle_time_period();
5211        assert_eq!(state.time_period, TimePeriod::Min15);
5212        state.cycle_time_period();
5213        assert_eq!(state.time_period, TimePeriod::Hour1);
5214    }
5215
5216    #[test]
5217    fn test_set_specific_time_period() {
5218        let token_data = create_test_token_data();
5219        let mut state = MonitorState::new(&token_data, "ethereum");
5220        state.set_time_period(TimePeriod::Day1);
5221        assert_eq!(state.time_period, TimePeriod::Day1);
5222    }
5223
5224    #[test]
5225    fn test_pause_resume_roundtrip() {
5226        let token_data = create_test_token_data();
5227        let mut state = MonitorState::new(&token_data, "ethereum");
5228        assert!(!state.paused);
5229        state.toggle_pause();
5230        assert!(state.paused);
5231        state.toggle_pause();
5232        assert!(!state.paused);
5233    }
5234
5235    #[test]
5236    fn test_force_refresh_unpauses() {
5237        let token_data = create_test_token_data();
5238        let mut state = MonitorState::new(&token_data, "ethereum");
5239        state.paused = true;
5240        state.force_refresh();
5241        assert!(!state.paused);
5242        assert!(state.should_refresh());
5243    }
5244
5245    #[test]
5246    fn test_refresh_rate_adjust() {
5247        let token_data = create_test_token_data();
5248        let mut state = MonitorState::new(&token_data, "ethereum");
5249        assert_eq!(state.refresh_rate_secs(), 5);
5250
5251        state.slower_refresh();
5252        assert_eq!(state.refresh_rate_secs(), 10);
5253
5254        state.faster_refresh();
5255        assert_eq!(state.refresh_rate_secs(), 5);
5256    }
5257
5258    #[test]
5259    fn test_faster_refresh_clamped_min() {
5260        let token_data = create_test_token_data();
5261        let mut state = MonitorState::new(&token_data, "ethereum");
5262        for _ in 0..10 {
5263            state.faster_refresh();
5264        }
5265        assert!(state.refresh_rate_secs() >= 1);
5266    }
5267
5268    #[test]
5269    fn test_slower_refresh_clamped_max() {
5270        let token_data = create_test_token_data();
5271        let mut state = MonitorState::new(&token_data, "ethereum");
5272        for _ in 0..20 {
5273            state.slower_refresh();
5274        }
5275        assert!(state.refresh_rate_secs() <= 60);
5276    }
5277
5278    #[test]
5279    fn test_buy_ratio_balanced() {
5280        let mut token_data = create_test_token_data();
5281        token_data.total_buys_24h = 100;
5282        token_data.total_sells_24h = 100;
5283        let state = MonitorState::new(&token_data, "ethereum");
5284        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5285    }
5286
5287    #[test]
5288    fn test_buy_ratio_no_trades() {
5289        let mut token_data = create_test_token_data();
5290        token_data.total_buys_24h = 0;
5291        token_data.total_sells_24h = 0;
5292        let state = MonitorState::new(&token_data, "ethereum");
5293        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5294    }
5295
5296    #[test]
5297    fn test_data_stats_initial() {
5298        let token_data = create_test_token_data();
5299        let state = MonitorState::new(&token_data, "ethereum");
5300        let (synthetic, real) = state.data_stats();
5301        assert!(synthetic > 0 || real == 0);
5302    }
5303
5304    #[test]
5305    fn test_memory_usage_nonzero() {
5306        let token_data = create_test_token_data();
5307        let state = MonitorState::new(&token_data, "ethereum");
5308        let usage = state.memory_usage();
5309        assert!(usage > 0);
5310    }
5311
5312    #[test]
5313    fn test_price_data_for_period() {
5314        let token_data = create_test_token_data();
5315        let state = MonitorState::new(&token_data, "ethereum");
5316        let (data, is_real) = state.get_price_data_for_period();
5317        assert_eq!(data.len(), is_real.len());
5318    }
5319
5320    #[test]
5321    fn test_volume_data_for_period() {
5322        let token_data = create_test_token_data();
5323        let state = MonitorState::new(&token_data, "ethereum");
5324        let (data, is_real) = state.get_volume_data_for_period();
5325        assert_eq!(data.len(), is_real.len());
5326    }
5327
5328    #[test]
5329    fn test_ohlc_candles_generation() {
5330        let token_data = create_test_token_data();
5331        let state = MonitorState::new(&token_data, "ethereum");
5332        let candles = state.get_ohlc_candles();
5333        for candle in &candles {
5334            assert!(candle.high >= candle.low);
5335        }
5336    }
5337
5338    #[test]
5339    fn test_state_update_with_new_data() {
5340        let token_data = create_test_token_data();
5341        let mut state = MonitorState::new(&token_data, "ethereum");
5342        let initial_count = state.real_data_count;
5343
5344        let mut updated_data = create_test_token_data();
5345        updated_data.price_usd = 2.0;
5346        updated_data.volume_24h = 2_000_000.0;
5347
5348        state.update(&updated_data);
5349        assert_eq!(state.current_price, 2.0);
5350        assert_eq!(state.real_data_count, initial_count + 1);
5351        assert!(state.error_message.is_none());
5352    }
5353
5354    #[test]
5355    fn test_cache_roundtrip_save_load() {
5356        let token_data = create_test_token_data();
5357        let state = MonitorState::new(&token_data, "ethereum");
5358
5359        state.save_cache();
5360
5361        let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
5362        assert!(cache_path.exists());
5363
5364        let cached = MonitorState::load_cache(&token_data.address, "ethereum");
5365        assert!(cached.is_some());
5366
5367        let _ = std::fs::remove_file(cache_path);
5368    }
5369
5370    #[test]
5371    fn test_should_refresh_when_paused() {
5372        let token_data = create_test_token_data();
5373        let mut state = MonitorState::new(&token_data, "ethereum");
5374        assert!(!state.should_refresh());
5375        state.paused = true;
5376        assert!(!state.should_refresh());
5377    }
5378
5379    #[test]
5380    fn test_ohlc_candle_lifecycle() {
5381        let mut candle = OhlcCandle::new(1700000000.0, 100.0);
5382        assert_eq!(candle.open, 100.0);
5383        assert!(candle.is_bullish);
5384        candle.update(110.0);
5385        assert_eq!(candle.high, 110.0);
5386        assert!(candle.is_bullish);
5387        candle.update(90.0);
5388        assert_eq!(candle.low, 90.0);
5389        assert!(!candle.is_bullish);
5390    }
5391
5392    #[test]
5393    fn test_time_period_display_impl() {
5394        assert_eq!(format!("{}", TimePeriod::Min1), "1m");
5395        assert_eq!(format!("{}", TimePeriod::Min15), "15m");
5396        assert_eq!(format!("{}", TimePeriod::Day1), "1d");
5397    }
5398
5399    #[test]
5400    fn test_log_messages_accumulate() {
5401        let token_data = create_test_token_data();
5402        let mut state = MonitorState::new(&token_data, "ethereum");
5403        // Trigger actions that log
5404        state.toggle_pause();
5405        state.toggle_pause();
5406        state.cycle_time_period();
5407        state.toggle_chart_mode();
5408        assert!(!state.log_messages.is_empty());
5409    }
5410
5411    #[test]
5412    fn test_ui_function_full_render() {
5413        // Test the main ui() function which orchestrates all rendering
5414        let mut terminal = create_test_terminal();
5415        let mut state = create_populated_state();
5416        terminal.draw(|f| ui(f, &mut state)).unwrap();
5417    }
5418
5419    #[test]
5420    fn test_ui_function_candlestick_mode() {
5421        let mut terminal = create_test_terminal();
5422        let mut state = create_populated_state();
5423        state.chart_mode = ChartMode::Candlestick;
5424        terminal.draw(|f| ui(f, &mut state)).unwrap();
5425    }
5426
5427    #[test]
5428    fn test_ui_function_with_error_message() {
5429        let mut terminal = create_test_terminal();
5430        let mut state = create_populated_state();
5431        state.error_message = Some("Test error".to_string());
5432        terminal.draw(|f| ui(f, &mut state)).unwrap();
5433    }
5434
5435    #[test]
5436    fn test_render_header_with_small_positive_change() {
5437        let mut terminal = create_test_terminal();
5438        let mut state = create_populated_state();
5439        state.price_change_24h = 0.3; // Between 0 and 0.5 -> △
5440        terminal
5441            .draw(|f| render_header(f, f.area(), &state))
5442            .unwrap();
5443    }
5444
5445    #[test]
5446    fn test_render_header_with_small_negative_change() {
5447        let mut terminal = create_test_terminal();
5448        let mut state = create_populated_state();
5449        state.price_change_24h = -0.3; // Between -0.5 and 0 -> ▽
5450        terminal
5451            .draw(|f| render_header(f, f.area(), &state))
5452            .unwrap();
5453    }
5454
5455    #[test]
5456    fn test_render_buy_sell_gauge_high_buy_ratio() {
5457        let mut terminal = create_test_terminal();
5458        let token_data = create_test_token_data();
5459        let mut state = MonitorState::new(&token_data, "ethereum");
5460        state.buys_24h = 100;
5461        state.sells_24h = 10;
5462        terminal
5463            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5464            .unwrap();
5465    }
5466
5467    #[test]
5468    fn test_render_buy_sell_gauge_zero_total() {
5469        let mut terminal = create_test_terminal();
5470        let token_data = create_test_token_data();
5471        let mut state = MonitorState::new(&token_data, "ethereum");
5472        state.buys_24h = 0;
5473        state.sells_24h = 0;
5474        terminal
5475            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5476            .unwrap();
5477    }
5478
5479    #[test]
5480    fn test_render_metrics_with_market_cap() {
5481        let mut terminal = create_test_terminal();
5482        let token_data = create_test_token_data();
5483        let mut state = MonitorState::new(&token_data, "ethereum");
5484        state.market_cap = Some(1_000_000_000.0);
5485        state.fdv = Some(2_000_000_000.0);
5486        terminal
5487            .draw(|f| render_metrics_panel(f, f.area(), &state))
5488            .unwrap();
5489    }
5490
5491    #[test]
5492    fn test_render_footer_with_error() {
5493        let mut terminal = create_test_terminal();
5494        let mut state = create_populated_state();
5495        state.error_message = Some("Connection failed".to_string());
5496        terminal
5497            .draw(|f| render_footer(f, f.area(), &state))
5498            .unwrap();
5499    }
5500
5501    #[test]
5502    fn test_format_price_usd_various() {
5503        // Test format_price_usd with various magnitudes
5504        assert!(!format_price_usd(0.0000001).is_empty());
5505        assert!(!format_price_usd(0.001).is_empty());
5506        assert!(!format_price_usd(1.0).is_empty());
5507        assert!(!format_price_usd(100.0).is_empty());
5508        assert!(!format_price_usd(10000.0).is_empty());
5509        assert!(!format_price_usd(1000000.0).is_empty());
5510    }
5511
5512    #[test]
5513    fn test_format_usd_various() {
5514        assert!(!crate::display::format_usd(0.0).is_empty());
5515        assert!(!crate::display::format_usd(999.0).is_empty());
5516        assert!(!crate::display::format_usd(1500.0).is_empty());
5517        assert!(!crate::display::format_usd(1_500_000.0).is_empty());
5518        assert!(!crate::display::format_usd(1_500_000_000.0).is_empty());
5519        assert!(!crate::display::format_usd(1_500_000_000_000.0).is_empty());
5520    }
5521
5522    #[test]
5523    fn test_format_number_various() {
5524        assert!(!format_number(0.0).is_empty());
5525        assert!(!format_number(999.0).is_empty());
5526        assert!(!format_number(1500.0).is_empty());
5527        assert!(!format_number(1_500_000.0).is_empty());
5528        assert!(!format_number(1_500_000_000.0).is_empty());
5529    }
5530
5531    #[test]
5532    fn test_render_with_min15_period() {
5533        let mut terminal = create_test_terminal();
5534        let mut state = create_populated_state();
5535        state.set_time_period(TimePeriod::Min15);
5536        terminal.draw(|f| ui(f, &mut state)).unwrap();
5537    }
5538
5539    #[test]
5540    fn test_render_with_hour6_period() {
5541        let mut terminal = create_test_terminal();
5542        let mut state = create_populated_state();
5543        state.set_time_period(TimePeriod::Hour4);
5544        terminal.draw(|f| ui(f, &mut state)).unwrap();
5545    }
5546
5547    #[test]
5548    fn test_ui_with_fresh_state_no_real_data() {
5549        let mut terminal = create_test_terminal();
5550        let token_data = create_test_token_data();
5551        let mut state = MonitorState::new(&token_data, "ethereum");
5552        // Fresh state with only synthetic data
5553        terminal.draw(|f| ui(f, &mut state)).unwrap();
5554    }
5555
5556    #[test]
5557    fn test_ui_with_paused_state() {
5558        let mut terminal = create_test_terminal();
5559        let mut state = create_populated_state();
5560        state.toggle_pause();
5561        terminal.draw(|f| ui(f, &mut state)).unwrap();
5562    }
5563
5564    #[test]
5565    fn test_render_all_with_different_time_periods_and_modes() {
5566        let mut terminal = create_test_terminal();
5567        let mut state = create_populated_state();
5568
5569        // Test all combinations of time period + chart mode
5570        for period in &[
5571            TimePeriod::Min1,
5572            TimePeriod::Min5,
5573            TimePeriod::Min15,
5574            TimePeriod::Hour1,
5575            TimePeriod::Hour4,
5576            TimePeriod::Day1,
5577        ] {
5578            for mode in &[
5579                ChartMode::Line,
5580                ChartMode::Candlestick,
5581                ChartMode::VolumeProfile,
5582            ] {
5583                state.set_time_period(*period);
5584                state.chart_mode = *mode;
5585                terminal.draw(|f| ui(f, &mut state)).unwrap();
5586            }
5587        }
5588    }
5589
5590    #[test]
5591    fn test_render_metrics_with_large_values() {
5592        let mut terminal = create_test_terminal();
5593        let mut state = create_populated_state();
5594        state.market_cap = Some(50_000_000_000.0); // 50B
5595        state.fdv = Some(100_000_000_000.0); // 100B
5596        state.volume_24h = 5_000_000_000.0; // 5B
5597        state.liquidity_usd = 500_000_000.0; // 500M
5598        terminal
5599            .draw(|f| render_metrics_panel(f, f.area(), &state))
5600            .unwrap();
5601    }
5602
5603    #[test]
5604    fn test_render_header_large_positive_change() {
5605        let mut terminal = create_test_terminal();
5606        let mut state = create_populated_state();
5607        state.price_change_24h = 50.0; // >0.5 -> ▲
5608        terminal
5609            .draw(|f| render_header(f, f.area(), &state))
5610            .unwrap();
5611    }
5612
5613    #[test]
5614    fn test_render_header_large_negative_change() {
5615        let mut terminal = create_test_terminal();
5616        let mut state = create_populated_state();
5617        state.price_change_24h = -50.0; // <-0.5 -> ▼
5618        terminal
5619            .draw(|f| render_header(f, f.area(), &state))
5620            .unwrap();
5621    }
5622
5623    #[test]
5624    fn test_render_price_chart_empty_data() {
5625        let mut terminal = create_test_terminal();
5626        let token_data = create_test_token_data();
5627        // Create state with no price history data
5628        let mut state = MonitorState::new(&token_data, "ethereum");
5629        state.price_history.clear();
5630        terminal
5631            .draw(|f| render_price_chart(f, f.area(), &state))
5632            .unwrap();
5633    }
5634
5635    #[test]
5636    fn test_render_price_chart_price_down() {
5637        let mut terminal = create_test_terminal();
5638        let mut state = create_populated_state();
5639        // Force price down scenario
5640        state.price_change_24h = -15.0;
5641        state.current_price = 0.5; // Below initial
5642        terminal
5643            .draw(|f| render_price_chart(f, f.area(), &state))
5644            .unwrap();
5645    }
5646
5647    #[test]
5648    fn test_render_price_chart_zero_first_price() {
5649        let mut terminal = create_test_terminal();
5650        let mut token_data = create_test_token_data();
5651        token_data.price_usd = 0.0;
5652        let state = MonitorState::new(&token_data, "ethereum");
5653        terminal
5654            .draw(|f| render_price_chart(f, f.area(), &state))
5655            .unwrap();
5656    }
5657
5658    #[test]
5659    fn test_render_metrics_panel_zero_5m_change() {
5660        let mut terminal = create_test_terminal();
5661        let mut state = create_populated_state();
5662        state.price_change_5m = 0.0; // Exactly zero
5663        terminal
5664            .draw(|f| render_metrics_panel(f, f.area(), &state))
5665            .unwrap();
5666    }
5667
5668    #[test]
5669    fn test_render_metrics_panel_positive_5m_change() {
5670        let mut terminal = create_test_terminal();
5671        let mut state = create_populated_state();
5672        state.price_change_5m = 5.0; // Positive
5673        terminal
5674            .draw(|f| render_metrics_panel(f, f.area(), &state))
5675            .unwrap();
5676    }
5677
5678    #[test]
5679    fn test_render_metrics_panel_negative_5m_change() {
5680        let mut terminal = create_test_terminal();
5681        let mut state = create_populated_state();
5682        state.price_change_5m = -3.0; // Negative
5683        terminal
5684            .draw(|f| render_metrics_panel(f, f.area(), &state))
5685            .unwrap();
5686    }
5687
5688    #[test]
5689    fn test_render_metrics_panel_negative_24h_change() {
5690        let mut terminal = create_test_terminal();
5691        let mut state = create_populated_state();
5692        state.price_change_24h = -10.0;
5693        terminal
5694            .draw(|f| render_metrics_panel(f, f.area(), &state))
5695            .unwrap();
5696    }
5697
5698    #[test]
5699    fn test_render_metrics_panel_old_last_change() {
5700        let mut terminal = create_test_terminal();
5701        let mut state = create_populated_state();
5702        // Set last_price_change_at to over an hour ago
5703        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; // 2h ago
5704        terminal
5705            .draw(|f| render_metrics_panel(f, f.area(), &state))
5706            .unwrap();
5707    }
5708
5709    #[test]
5710    fn test_render_metrics_panel_minutes_ago_change() {
5711        let mut terminal = create_test_terminal();
5712        let mut state = create_populated_state();
5713        // Set last_price_change_at to minutes ago
5714        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; // 5 min ago
5715        terminal
5716            .draw(|f| render_metrics_panel(f, f.area(), &state))
5717            .unwrap();
5718    }
5719
5720    #[test]
5721    fn test_render_candlestick_empty_fresh_state() {
5722        let mut terminal = create_test_terminal();
5723        let token_data = create_test_token_data();
5724        let mut state = MonitorState::new(&token_data, "ethereum");
5725        state.price_history.clear();
5726        state.chart_mode = ChartMode::Candlestick;
5727        terminal
5728            .draw(|f| render_candlestick_chart(f, f.area(), &state))
5729            .unwrap();
5730    }
5731
5732    #[test]
5733    fn test_render_candlestick_price_down() {
5734        let mut terminal = create_test_terminal();
5735        let token_data = create_test_token_data();
5736        let mut state = MonitorState::new(&token_data, "ethereum");
5737        // Add data going down
5738        for i in 0..20 {
5739            let mut data = token_data.clone();
5740            data.price_usd = 2.0 - (i as f64 * 0.05);
5741            state.update(&data);
5742        }
5743        state.chart_mode = ChartMode::Candlestick;
5744        terminal
5745            .draw(|f| render_candlestick_chart(f, f.area(), &state))
5746            .unwrap();
5747    }
5748
5749    #[test]
5750    fn test_render_volume_chart_with_many_points() {
5751        let mut terminal = create_test_terminal();
5752        let token_data = create_test_token_data();
5753        let mut state = MonitorState::new(&token_data, "ethereum");
5754        // Add lots of data points
5755        for i in 0..100 {
5756            let mut data = token_data.clone();
5757            data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
5758            data.price_usd = 1.0 + (i as f64 * 0.001);
5759            state.update(&data);
5760        }
5761        terminal
5762            .draw(|f| render_volume_chart(f, f.area(), &state))
5763            .unwrap();
5764    }
5765
5766    // ========================================================================
5767    // Key event handler tests
5768    // ========================================================================
5769
5770    fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5771        crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
5772    }
5773
5774    fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5775        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
5776    }
5777
5778    #[test]
5779    fn test_handle_key_quit_q() {
5780        let token_data = create_test_token_data();
5781        let mut state = MonitorState::new(&token_data, "ethereum");
5782        assert!(handle_key_event_on_state(
5783            make_key_event(KeyCode::Char('q')),
5784            &mut state
5785        ));
5786    }
5787
5788    #[test]
5789    fn test_handle_key_quit_esc() {
5790        let token_data = create_test_token_data();
5791        let mut state = MonitorState::new(&token_data, "ethereum");
5792        assert!(handle_key_event_on_state(
5793            make_key_event(KeyCode::Esc),
5794            &mut state
5795        ));
5796    }
5797
5798    #[test]
5799    fn test_handle_key_quit_ctrl_c() {
5800        let token_data = create_test_token_data();
5801        let mut state = MonitorState::new(&token_data, "ethereum");
5802        assert!(handle_key_event_on_state(
5803            make_ctrl_key_event(KeyCode::Char('c')),
5804            &mut state
5805        ));
5806    }
5807
5808    #[test]
5809    fn test_handle_key_refresh() {
5810        let token_data = create_test_token_data();
5811        let mut state = MonitorState::new(&token_data, "ethereum");
5812        state.refresh_rate = Duration::from_secs(60);
5813        // Set last_update in the past so should_refresh was false
5814        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
5815        assert!(!exit);
5816        // force_refresh sets last_update to epoch, so should_refresh() should be true
5817        assert!(state.should_refresh());
5818    }
5819
5820    #[test]
5821    fn test_handle_key_pause_toggle() {
5822        let token_data = create_test_token_data();
5823        let mut state = MonitorState::new(&token_data, "ethereum");
5824        assert!(!state.paused);
5825
5826        handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
5827        assert!(state.paused);
5828
5829        handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
5830        assert!(!state.paused);
5831    }
5832
5833    #[test]
5834    fn test_handle_key_slower_refresh() {
5835        let token_data = create_test_token_data();
5836        let mut state = MonitorState::new(&token_data, "ethereum");
5837        let initial = state.refresh_rate;
5838
5839        handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
5840        assert!(state.refresh_rate > initial);
5841
5842        state.refresh_rate = initial;
5843        handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
5844        assert!(state.refresh_rate > initial);
5845
5846        state.refresh_rate = initial;
5847        handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
5848        assert!(state.refresh_rate > initial);
5849    }
5850
5851    #[test]
5852    fn test_handle_key_faster_refresh() {
5853        let token_data = create_test_token_data();
5854        let mut state = MonitorState::new(&token_data, "ethereum");
5855        // First make it slower so there's room to go faster
5856        state.refresh_rate = Duration::from_secs(30);
5857        let initial = state.refresh_rate;
5858
5859        handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
5860        assert!(state.refresh_rate < initial);
5861
5862        state.refresh_rate = initial;
5863        handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
5864        assert!(state.refresh_rate < initial);
5865
5866        state.refresh_rate = initial;
5867        handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
5868        assert!(state.refresh_rate < initial);
5869    }
5870
5871    #[test]
5872    fn test_handle_key_time_periods() {
5873        let token_data = create_test_token_data();
5874        let mut state = MonitorState::new(&token_data, "ethereum");
5875
5876        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
5877        assert!(matches!(state.time_period, TimePeriod::Min1));
5878
5879        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
5880        assert!(matches!(state.time_period, TimePeriod::Min5));
5881
5882        handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
5883        assert!(matches!(state.time_period, TimePeriod::Min15));
5884
5885        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
5886        assert!(matches!(state.time_period, TimePeriod::Hour1));
5887
5888        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
5889        assert!(matches!(state.time_period, TimePeriod::Hour4));
5890
5891        handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
5892        assert!(matches!(state.time_period, TimePeriod::Day1));
5893    }
5894
5895    #[test]
5896    fn test_handle_key_cycle_time_period() {
5897        let token_data = create_test_token_data();
5898        let mut state = MonitorState::new(&token_data, "ethereum");
5899
5900        handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
5901        // Should cycle from default
5902        let first = state.time_period;
5903
5904        handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
5905        // Should have cycled again
5906        // Verify it cycled (no panic is the main check)
5907        let _ = state.time_period;
5908        let _ = first;
5909    }
5910
5911    #[test]
5912    fn test_handle_key_toggle_chart_mode() {
5913        let token_data = create_test_token_data();
5914        let mut state = MonitorState::new(&token_data, "ethereum");
5915        let initial_mode = state.chart_mode;
5916
5917        handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
5918        assert!(state.chart_mode != initial_mode);
5919    }
5920
5921    #[test]
5922    fn test_handle_key_unknown_no_op() {
5923        let token_data = create_test_token_data();
5924        let mut state = MonitorState::new(&token_data, "ethereum");
5925        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
5926        assert!(!exit);
5927    }
5928
5929    // ========================================================================
5930    // Cache save/load tests
5931    // ========================================================================
5932
5933    #[test]
5934    fn test_save_and_load_cache() {
5935        let token_data = create_test_token_data();
5936        let mut state = MonitorState::new(&token_data, "ethereum");
5937        state.price_history.push_back(DataPoint {
5938            timestamp: 1.0,
5939            value: 100.0,
5940            is_real: true,
5941        });
5942        state.price_history.push_back(DataPoint {
5943            timestamp: 2.0,
5944            value: 101.0,
5945            is_real: true,
5946        });
5947        state.volume_history.push_back(DataPoint {
5948            timestamp: 1.0,
5949            value: 5000.0,
5950            is_real: true,
5951        });
5952
5953        // save_cache uses dirs::cache_dir() which we can't redirect easily
5954        // but we can test the load_cache path with a real write
5955        state.save_cache();
5956        let cached = MonitorState::load_cache(&state.token_address, &state.chain);
5957        // Cache may or may not exist depending on system - just verify no panic
5958        if let Some(c) = cached {
5959            assert_eq!(
5960                c.token_address.to_lowercase(),
5961                state.token_address.to_lowercase()
5962            );
5963        }
5964    }
5965
5966    #[test]
5967    fn test_load_cache_nonexistent_token() {
5968        let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
5969        assert!(cached.is_none());
5970    }
5971
5972    // ========================================================================
5973    // New widget tests: BarChart (volume), Table+Sparkline (metrics), scroll
5974    // ========================================================================
5975
5976    #[test]
5977    fn test_render_volume_barchart_with_populated_data() {
5978        // Verify the BarChart-based volume chart renders without panic
5979        // when state has many volume data points across different time periods
5980        let mut terminal = create_test_terminal();
5981        let mut state = create_populated_state();
5982        for period in [
5983            TimePeriod::Min1,
5984            TimePeriod::Min5,
5985            TimePeriod::Min15,
5986            TimePeriod::Hour1,
5987            TimePeriod::Hour4,
5988            TimePeriod::Day1,
5989        ] {
5990            state.set_time_period(period);
5991            terminal
5992                .draw(|f| render_volume_chart(f, f.area(), &state))
5993                .unwrap();
5994        }
5995    }
5996
5997    #[test]
5998    fn test_render_volume_barchart_narrow_terminal() {
5999        // BarChart with very narrow width should still render without panic
6000        let backend = TestBackend::new(20, 10);
6001        let mut terminal = Terminal::new(backend).unwrap();
6002        let state = create_populated_state();
6003        terminal
6004            .draw(|f| render_volume_chart(f, f.area(), &state))
6005            .unwrap();
6006    }
6007
6008    #[test]
6009    fn test_render_metrics_table_sparkline_no_panic() {
6010        // Verify the Table+Sparkline metrics panel renders without panic
6011        let mut terminal = create_test_terminal();
6012        let state = create_populated_state();
6013        terminal
6014            .draw(|f| render_metrics_panel(f, f.area(), &state))
6015            .unwrap();
6016    }
6017
6018    #[test]
6019    fn test_render_metrics_table_sparkline_all_periods() {
6020        // Ensure metrics panel renders correctly for every time period
6021        let mut terminal = create_test_terminal();
6022        let mut state = create_populated_state();
6023        for period in [
6024            TimePeriod::Min1,
6025            TimePeriod::Min5,
6026            TimePeriod::Min15,
6027            TimePeriod::Hour1,
6028            TimePeriod::Hour4,
6029            TimePeriod::Day1,
6030        ] {
6031            state.set_time_period(period);
6032            terminal
6033                .draw(|f| render_metrics_panel(f, f.area(), &state))
6034                .unwrap();
6035        }
6036    }
6037
6038    #[test]
6039    fn test_render_metrics_sparkline_trend_direction() {
6040        // When 5m change is negative, sparkline should still render
6041        let mut terminal = create_test_terminal();
6042        let mut state = create_populated_state();
6043        state.price_change_5m = -3.5;
6044        terminal
6045            .draw(|f| render_metrics_panel(f, f.area(), &state))
6046            .unwrap();
6047
6048        // When 5m change is positive
6049        state.price_change_5m = 2.0;
6050        terminal
6051            .draw(|f| render_metrics_panel(f, f.area(), &state))
6052            .unwrap();
6053
6054        // When 5m change is zero
6055        state.price_change_5m = 0.0;
6056        terminal
6057            .draw(|f| render_metrics_panel(f, f.area(), &state))
6058            .unwrap();
6059    }
6060
6061    #[test]
6062    fn test_render_tabs_time_period() {
6063        // Verify the Tabs widget in the header renders for each period
6064        let mut terminal = create_test_terminal();
6065        let mut state = create_populated_state();
6066        for period in [
6067            TimePeriod::Min1,
6068            TimePeriod::Min5,
6069            TimePeriod::Min15,
6070            TimePeriod::Hour1,
6071            TimePeriod::Hour4,
6072            TimePeriod::Day1,
6073        ] {
6074            state.set_time_period(period);
6075            terminal
6076                .draw(|f| render_header(f, f.area(), &state))
6077                .unwrap();
6078        }
6079    }
6080
6081    #[test]
6082    fn test_time_period_index() {
6083        assert_eq!(TimePeriod::Min1.index(), 0);
6084        assert_eq!(TimePeriod::Min5.index(), 1);
6085        assert_eq!(TimePeriod::Min15.index(), 2);
6086        assert_eq!(TimePeriod::Hour1.index(), 3);
6087        assert_eq!(TimePeriod::Hour4.index(), 4);
6088        assert_eq!(TimePeriod::Day1.index(), 5);
6089    }
6090
6091    #[test]
6092    fn test_scroll_log_down_from_start() {
6093        let token_data = create_test_token_data();
6094        let mut state = MonitorState::new(&token_data, "ethereum");
6095        state.log_messages.push_back("msg 1".to_string());
6096        state.log_messages.push_back("msg 2".to_string());
6097        state.log_messages.push_back("msg 3".to_string());
6098
6099        // Initially no selection
6100        assert_eq!(state.log_list_state.selected(), None);
6101
6102        // First scroll down selects item 0
6103        state.scroll_log_down();
6104        assert_eq!(state.log_list_state.selected(), Some(0));
6105
6106        // Second scroll moves to item 1
6107        state.scroll_log_down();
6108        assert_eq!(state.log_list_state.selected(), Some(1));
6109
6110        // Third scroll moves to item 2
6111        state.scroll_log_down();
6112        assert_eq!(state.log_list_state.selected(), Some(2));
6113
6114        // Fourth scroll stays at last item (bounds check)
6115        state.scroll_log_down();
6116        assert_eq!(state.log_list_state.selected(), Some(2));
6117    }
6118
6119    #[test]
6120    fn test_scroll_log_up_from_start() {
6121        let token_data = create_test_token_data();
6122        let mut state = MonitorState::new(&token_data, "ethereum");
6123        state.log_messages.push_back("msg 1".to_string());
6124        state.log_messages.push_back("msg 2".to_string());
6125        state.log_messages.push_back("msg 3".to_string());
6126
6127        // Scroll up from no selection goes to 0
6128        state.scroll_log_up();
6129        assert_eq!(state.log_list_state.selected(), Some(0));
6130
6131        // Can't go below 0
6132        state.scroll_log_up();
6133        assert_eq!(state.log_list_state.selected(), Some(0));
6134    }
6135
6136    #[test]
6137    fn test_scroll_log_up_down_roundtrip() {
6138        let token_data = create_test_token_data();
6139        let mut state = MonitorState::new(&token_data, "ethereum");
6140        for i in 0..10 {
6141            state.log_messages.push_back(format!("msg {}", i));
6142        }
6143
6144        // Scroll down 5 times
6145        for _ in 0..5 {
6146            state.scroll_log_down();
6147        }
6148        assert_eq!(state.log_list_state.selected(), Some(4));
6149
6150        // Scroll up 3 times
6151        for _ in 0..3 {
6152            state.scroll_log_up();
6153        }
6154        assert_eq!(state.log_list_state.selected(), Some(1));
6155    }
6156
6157    #[test]
6158    fn test_scroll_log_empty_no_panic() {
6159        let token_data = create_test_token_data();
6160        let mut state = MonitorState::new(&token_data, "ethereum");
6161        // With no log messages, scrolling should not panic
6162        state.scroll_log_down();
6163        state.scroll_log_up();
6164        assert!(
6165            state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
6166        );
6167    }
6168
6169    #[test]
6170    fn test_render_scrollable_activity_log() {
6171        // Ensure the stateful activity log renders without panic
6172        let mut terminal = create_test_terminal();
6173        let mut state = create_populated_state();
6174        for i in 0..20 {
6175            state
6176                .log_messages
6177                .push_back(format!("Activity event #{}", i));
6178        }
6179        // Scroll down a few items
6180        state.scroll_log_down();
6181        state.scroll_log_down();
6182        state.scroll_log_down();
6183
6184        terminal
6185            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
6186            .unwrap();
6187    }
6188
6189    #[test]
6190    fn test_handle_key_scroll_log_j_k() {
6191        let token_data = create_test_token_data();
6192        let mut state = MonitorState::new(&token_data, "ethereum");
6193        state.log_messages.push_back("line 1".to_string());
6194        state.log_messages.push_back("line 2".to_string());
6195
6196        // j scrolls down
6197        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6198        assert_eq!(state.log_list_state.selected(), Some(0));
6199
6200        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6201        assert_eq!(state.log_list_state.selected(), Some(1));
6202
6203        // k scrolls up
6204        handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
6205        assert_eq!(state.log_list_state.selected(), Some(0));
6206    }
6207
6208    #[test]
6209    fn test_handle_key_scroll_log_arrow_keys() {
6210        let token_data = create_test_token_data();
6211        let mut state = MonitorState::new(&token_data, "ethereum");
6212        state.log_messages.push_back("line 1".to_string());
6213        state.log_messages.push_back("line 2".to_string());
6214        state.log_messages.push_back("line 3".to_string());
6215
6216        // Down arrow scrolls down
6217        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6218        assert_eq!(state.log_list_state.selected(), Some(0));
6219
6220        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6221        assert_eq!(state.log_list_state.selected(), Some(1));
6222
6223        // Up arrow scrolls up
6224        handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
6225        assert_eq!(state.log_list_state.selected(), Some(0));
6226    }
6227
6228    #[test]
6229    fn test_render_ui_with_scrolled_log() {
6230        // Full UI render with a scrolled activity log position
6231        let mut terminal = create_test_terminal();
6232        let mut state = create_populated_state();
6233        for i in 0..15 {
6234            state.log_messages.push_back(format!("Log entry {}", i));
6235        }
6236        state.scroll_log_down();
6237        state.scroll_log_down();
6238        state.scroll_log_down();
6239        state.scroll_log_down();
6240        state.scroll_log_down();
6241
6242        terminal.draw(|f| ui(f, &mut state)).unwrap();
6243    }
6244
6245    // ========================================================================
6246    // Token selection / resolve tests
6247    // ========================================================================
6248
6249    fn make_monitor_search_results() -> Vec<crate::chains::dex::TokenSearchResult> {
6250        vec![
6251            crate::chains::dex::TokenSearchResult {
6252                symbol: "USDC".to_string(),
6253                name: "USD Coin".to_string(),
6254                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
6255                chain: "ethereum".to_string(),
6256                price_usd: Some(1.0),
6257                volume_24h: 1_000_000.0,
6258                liquidity_usd: 500_000_000.0,
6259                market_cap: Some(30_000_000_000.0),
6260            },
6261            crate::chains::dex::TokenSearchResult {
6262                symbol: "USDC".to_string(),
6263                name: "Bridged USD Coin".to_string(),
6264                address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
6265                chain: "ethereum".to_string(),
6266                price_usd: Some(0.9998),
6267                volume_24h: 500_000.0,
6268                liquidity_usd: 100_000_000.0,
6269                market_cap: None,
6270            },
6271            crate::chains::dex::TokenSearchResult {
6272                symbol: "USDC".to_string(),
6273                name: "A Very Long Token Name That Exceeds The Limit".to_string(),
6274                address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
6275                chain: "ethereum".to_string(),
6276                price_usd: None,
6277                volume_24h: 0.0,
6278                liquidity_usd: 50_000.0,
6279                market_cap: None,
6280            },
6281        ]
6282    }
6283
6284    #[test]
6285    fn test_abbreviate_address_long() {
6286        let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
6287        let abbr = abbreviate_address(addr);
6288        assert_eq!(abbr, "0xA0b869...06eB48");
6289        assert!(abbr.contains("..."));
6290    }
6291
6292    #[test]
6293    fn test_abbreviate_address_short() {
6294        let addr = "0x1234abcd";
6295        let abbr = abbreviate_address(addr);
6296        // Short addresses are not abbreviated
6297        assert_eq!(abbr, "0x1234abcd");
6298    }
6299
6300    #[test]
6301    fn test_select_token_impl_first() {
6302        let results = make_monitor_search_results();
6303        let input = b"1\n";
6304        let mut reader = std::io::Cursor::new(&input[..]);
6305        let mut writer = Vec::new();
6306
6307        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6308        assert_eq!(selected.name, "USD Coin");
6309        assert_eq!(
6310            selected.address,
6311            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
6312        );
6313
6314        let output = String::from_utf8(writer).unwrap();
6315        assert!(output.contains("Found 3 tokens"));
6316        assert!(output.contains("USDC"));
6317        assert!(output.contains("0xA0b869...06eB48"));
6318        assert!(output.contains("Selected:"));
6319    }
6320
6321    #[test]
6322    fn test_select_token_impl_second() {
6323        let results = make_monitor_search_results();
6324        let input = b"2\n";
6325        let mut reader = std::io::Cursor::new(&input[..]);
6326        let mut writer = Vec::new();
6327
6328        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6329        assert_eq!(selected.name, "Bridged USD Coin");
6330        assert_eq!(
6331            selected.address,
6332            "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
6333        );
6334    }
6335
6336    #[test]
6337    fn test_select_token_impl_shows_address_column() {
6338        let results = make_monitor_search_results();
6339        let input = b"1\n";
6340        let mut reader = std::io::Cursor::new(&input[..]);
6341        let mut writer = Vec::new();
6342
6343        select_token_impl(&results, &mut reader, &mut writer).unwrap();
6344        let output = String::from_utf8(writer).unwrap();
6345
6346        // Table header should include Address column
6347        assert!(output.contains("Address"));
6348        // All three abbreviated addresses should appear
6349        assert!(output.contains("0xA0b869...06eB48"));
6350        assert!(output.contains("0x2791Bc...a84174"));
6351        assert!(output.contains("0x123456...345678"));
6352    }
6353
6354    #[test]
6355    fn test_select_token_impl_truncates_long_name() {
6356        let results = make_monitor_search_results();
6357        let input = b"3\n";
6358        let mut reader = std::io::Cursor::new(&input[..]);
6359        let mut writer = Vec::new();
6360
6361        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6362        assert_eq!(
6363            selected.address,
6364            "0x1234567890abcdef1234567890abcdef12345678"
6365        );
6366
6367        let output = String::from_utf8(writer).unwrap();
6368        assert!(output.contains("A Very Long Token..."));
6369    }
6370
6371    #[test]
6372    fn test_select_token_impl_invalid_input() {
6373        let results = make_monitor_search_results();
6374        let input = b"xyz\n";
6375        let mut reader = std::io::Cursor::new(&input[..]);
6376        let mut writer = Vec::new();
6377
6378        let result = select_token_impl(&results, &mut reader, &mut writer);
6379        assert!(result.is_err());
6380        assert!(
6381            result
6382                .unwrap_err()
6383                .to_string()
6384                .contains("Invalid selection")
6385        );
6386    }
6387
6388    #[test]
6389    fn test_select_token_impl_out_of_range_zero() {
6390        let results = make_monitor_search_results();
6391        let input = b"0\n";
6392        let mut reader = std::io::Cursor::new(&input[..]);
6393        let mut writer = Vec::new();
6394
6395        let result = select_token_impl(&results, &mut reader, &mut writer);
6396        assert!(result.is_err());
6397        assert!(
6398            result
6399                .unwrap_err()
6400                .to_string()
6401                .contains("Selection must be between")
6402        );
6403    }
6404
6405    #[test]
6406    fn test_select_token_impl_out_of_range_high() {
6407        let results = make_monitor_search_results();
6408        let input = b"99\n";
6409        let mut reader = std::io::Cursor::new(&input[..]);
6410        let mut writer = Vec::new();
6411
6412        let result = select_token_impl(&results, &mut reader, &mut writer);
6413        assert!(result.is_err());
6414    }
6415
6416    #[test]
6417    fn test_format_monitor_number() {
6418        assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
6419        assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
6420        assert_eq!(format_monitor_number(75_000.0), "$75.00K");
6421        assert_eq!(format_monitor_number(42.5), "$42.50");
6422    }
6423
6424    // ============================
6425    // Phase 4: Layout system tests
6426    // ============================
6427
6428    #[test]
6429    fn test_monitor_config_defaults() {
6430        let config = MonitorConfig::default();
6431        assert_eq!(config.layout, LayoutPreset::Dashboard);
6432        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6433        assert!(config.widgets.price_chart);
6434        assert!(config.widgets.volume_chart);
6435        assert!(config.widgets.buy_sell_pressure);
6436        assert!(config.widgets.metrics_panel);
6437        assert!(config.widgets.activity_log);
6438    }
6439
6440    #[test]
6441    fn test_layout_preset_next_cycles() {
6442        assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
6443        assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
6444        assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
6445        assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
6446        assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
6447    }
6448
6449    #[test]
6450    fn test_layout_preset_prev_cycles() {
6451        assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
6452        assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
6453        assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
6454        assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
6455        assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
6456    }
6457
6458    #[test]
6459    fn test_layout_preset_full_cycle() {
6460        let start = LayoutPreset::Dashboard;
6461        let mut preset = start;
6462        for _ in 0..5 {
6463            preset = preset.next();
6464        }
6465        assert_eq!(preset, start);
6466    }
6467
6468    #[test]
6469    fn test_layout_preset_labels() {
6470        assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
6471        assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
6472        assert_eq!(LayoutPreset::Feed.label(), "Feed");
6473        assert_eq!(LayoutPreset::Compact.label(), "Compact");
6474        assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
6475    }
6476
6477    #[test]
6478    fn test_widget_visibility_default_all_visible() {
6479        let vis = WidgetVisibility::default();
6480        assert_eq!(vis.visible_count(), 5);
6481    }
6482
6483    #[test]
6484    fn test_widget_visibility_toggle_by_index() {
6485        let mut vis = WidgetVisibility::default();
6486        vis.toggle_by_index(1);
6487        assert!(!vis.price_chart);
6488        assert_eq!(vis.visible_count(), 4);
6489
6490        vis.toggle_by_index(2);
6491        assert!(!vis.volume_chart);
6492        assert_eq!(vis.visible_count(), 3);
6493
6494        vis.toggle_by_index(3);
6495        assert!(!vis.buy_sell_pressure);
6496        assert_eq!(vis.visible_count(), 2);
6497
6498        vis.toggle_by_index(4);
6499        assert!(!vis.metrics_panel);
6500        assert_eq!(vis.visible_count(), 1);
6501
6502        vis.toggle_by_index(5);
6503        assert!(!vis.activity_log);
6504        assert_eq!(vis.visible_count(), 0);
6505
6506        // Toggle back
6507        vis.toggle_by_index(1);
6508        assert!(vis.price_chart);
6509        assert_eq!(vis.visible_count(), 1);
6510    }
6511
6512    #[test]
6513    fn test_widget_visibility_toggle_invalid_index() {
6514        let mut vis = WidgetVisibility::default();
6515        vis.toggle_by_index(0);
6516        vis.toggle_by_index(6);
6517        vis.toggle_by_index(100);
6518        assert_eq!(vis.visible_count(), 5); // unchanged
6519    }
6520
6521    #[test]
6522    fn test_auto_select_layout_small_terminal() {
6523        let size = Rect::new(0, 0, 60, 20);
6524        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6525    }
6526
6527    #[test]
6528    fn test_auto_select_layout_narrow_terminal() {
6529        let size = Rect::new(0, 0, 100, 40);
6530        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6531    }
6532
6533    #[test]
6534    fn test_auto_select_layout_short_terminal() {
6535        let size = Rect::new(0, 0, 140, 28);
6536        assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
6537    }
6538
6539    #[test]
6540    fn test_auto_select_layout_large_terminal() {
6541        let size = Rect::new(0, 0, 160, 50);
6542        assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
6543    }
6544
6545    #[test]
6546    fn test_auto_select_layout_edge_80x24() {
6547        // Exactly at the threshold: width>=80 and height>=24, but width<120
6548        let size = Rect::new(0, 0, 80, 24);
6549        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6550    }
6551
6552    #[test]
6553    fn test_auto_select_layout_edge_79() {
6554        let size = Rect::new(0, 0, 79, 50);
6555        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6556    }
6557
6558    #[test]
6559    fn test_auto_select_layout_edge_23_height() {
6560        let size = Rect::new(0, 0, 160, 23);
6561        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6562    }
6563
6564    #[test]
6565    fn test_layout_dashboard_all_visible() {
6566        let area = Rect::new(0, 0, 120, 40);
6567        let vis = WidgetVisibility::default();
6568        let areas = layout_dashboard(area, &vis);
6569        assert!(areas.price_chart.is_some());
6570        assert!(areas.volume_chart.is_some());
6571        assert!(areas.buy_sell_gauge.is_some());
6572        assert!(areas.metrics_panel.is_some());
6573        assert!(areas.activity_feed.is_some());
6574    }
6575
6576    #[test]
6577    fn test_layout_dashboard_hidden_widget() {
6578        let area = Rect::new(0, 0, 120, 40);
6579        let vis = WidgetVisibility {
6580            price_chart: false,
6581            ..WidgetVisibility::default()
6582        };
6583        let areas = layout_dashboard(area, &vis);
6584        assert!(areas.price_chart.is_none());
6585        assert!(areas.volume_chart.is_some());
6586    }
6587
6588    #[test]
6589    fn test_layout_chart_focus_minimal_overlay() {
6590        let area = Rect::new(0, 0, 120, 40);
6591        let vis = WidgetVisibility::default();
6592        let areas = layout_chart_focus(area, &vis);
6593        assert!(areas.price_chart.is_some());
6594        assert!(areas.volume_chart.is_none()); // Hidden in chart-focus
6595        assert!(areas.buy_sell_gauge.is_none()); // Hidden in chart-focus
6596        assert!(areas.metrics_panel.is_some()); // Minimal stats overlay
6597        assert!(areas.activity_feed.is_none()); // Hidden in chart-focus
6598    }
6599
6600    #[test]
6601    fn test_layout_feed_activity_priority() {
6602        let area = Rect::new(0, 0, 120, 40);
6603        let vis = WidgetVisibility::default();
6604        let areas = layout_feed(area, &vis);
6605        assert!(areas.price_chart.is_none()); // Hidden in feed
6606        assert!(areas.volume_chart.is_none()); // Hidden in feed
6607        assert!(areas.buy_sell_gauge.is_some()); // Top row
6608        assert!(areas.metrics_panel.is_some()); // Top row
6609        assert!(areas.activity_feed.is_some()); // Dominates bottom 75%
6610    }
6611
6612    #[test]
6613    fn test_layout_compact_metrics_only() {
6614        let area = Rect::new(0, 0, 60, 20);
6615        let vis = WidgetVisibility::default();
6616        let areas = layout_compact(area, &vis);
6617        assert!(areas.price_chart.is_none()); // Hidden in compact
6618        assert!(areas.volume_chart.is_none()); // Hidden in compact
6619        assert!(areas.buy_sell_gauge.is_none()); // Hidden in compact
6620        assert!(areas.metrics_panel.is_some()); // Full area
6621        assert!(areas.activity_feed.is_none()); // Hidden in compact
6622    }
6623
6624    #[test]
6625    fn test_layout_exchange_has_order_book_and_market_info() {
6626        let area = Rect::new(0, 0, 160, 50);
6627        let vis = WidgetVisibility::default();
6628        let areas = layout_exchange(area, &vis);
6629        assert!(areas.order_book.is_some());
6630        assert!(areas.market_info.is_some());
6631        assert!(areas.price_chart.is_some());
6632        assert!(areas.buy_sell_gauge.is_some());
6633        assert!(areas.volume_chart.is_none()); // Not in exchange layout
6634        assert!(areas.metrics_panel.is_none()); // Not in exchange layout
6635        assert!(areas.activity_feed.is_none()); // Not in exchange layout
6636    }
6637
6638    #[test]
6639    fn test_ui_render_all_layouts_no_panic() {
6640        let presets = [
6641            LayoutPreset::Dashboard,
6642            LayoutPreset::ChartFocus,
6643            LayoutPreset::Feed,
6644            LayoutPreset::Compact,
6645            LayoutPreset::Exchange,
6646        ];
6647        for preset in &presets {
6648            let mut terminal = create_test_terminal();
6649            let mut state = create_populated_state();
6650            state.layout = *preset;
6651            state.auto_layout = false; // Don't override during render
6652            terminal.draw(|f| ui(f, &mut state)).unwrap();
6653        }
6654    }
6655
6656    #[test]
6657    fn test_ui_render_compact_small_terminal() {
6658        let backend = TestBackend::new(60, 20);
6659        let mut terminal = Terminal::new(backend).unwrap();
6660        let mut state = create_populated_state();
6661        state.layout = LayoutPreset::Compact;
6662        state.auto_layout = false;
6663        terminal.draw(|f| ui(f, &mut state)).unwrap();
6664    }
6665
6666    #[test]
6667    fn test_ui_auto_layout_selects_compact_for_small() {
6668        let backend = TestBackend::new(60, 20);
6669        let mut terminal = Terminal::new(backend).unwrap();
6670        let mut state = create_populated_state();
6671        state.layout = LayoutPreset::Dashboard;
6672        state.auto_layout = true;
6673        terminal.draw(|f| ui(f, &mut state)).unwrap();
6674        assert_eq!(state.layout, LayoutPreset::Compact);
6675    }
6676
6677    #[test]
6678    fn test_ui_auto_layout_disabled_keeps_preset() {
6679        let backend = TestBackend::new(60, 20);
6680        let mut terminal = Terminal::new(backend).unwrap();
6681        let mut state = create_populated_state();
6682        state.layout = LayoutPreset::Dashboard;
6683        state.auto_layout = false;
6684        terminal.draw(|f| ui(f, &mut state)).unwrap();
6685        assert_eq!(state.layout, LayoutPreset::Dashboard); // Not changed
6686    }
6687
6688    #[test]
6689    fn test_keybinding_l_cycles_layout_forward() {
6690        let mut state = create_populated_state();
6691        state.layout = LayoutPreset::Dashboard;
6692        state.auto_layout = true;
6693
6694        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6695        assert_eq!(state.layout, LayoutPreset::ChartFocus);
6696        assert!(!state.auto_layout); // Manual switch disables auto
6697
6698        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6699        assert_eq!(state.layout, LayoutPreset::Feed);
6700    }
6701
6702    #[test]
6703    fn test_keybinding_h_cycles_layout_backward() {
6704        let mut state = create_populated_state();
6705        state.layout = LayoutPreset::Dashboard;
6706        state.auto_layout = true;
6707
6708        handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
6709        assert_eq!(state.layout, LayoutPreset::Exchange);
6710        assert!(!state.auto_layout);
6711    }
6712
6713    #[test]
6714    fn test_keybinding_a_enables_auto_layout() {
6715        let mut state = create_populated_state();
6716        state.auto_layout = false;
6717
6718        handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
6719        assert!(state.auto_layout);
6720    }
6721
6722    #[test]
6723    fn test_keybinding_w_widget_toggle_mode() {
6724        let mut state = create_populated_state();
6725        assert!(!state.widget_toggle_mode);
6726
6727        // Press w to enter toggle mode
6728        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6729        assert!(state.widget_toggle_mode);
6730
6731        // Press 1 to toggle price_chart off
6732        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
6733        assert!(!state.widget_toggle_mode);
6734        assert!(!state.widgets.price_chart);
6735    }
6736
6737    #[test]
6738    fn test_keybinding_w_cancel_with_non_digit() {
6739        let mut state = create_populated_state();
6740
6741        // Enter widget toggle mode
6742        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6743        assert!(state.widget_toggle_mode);
6744
6745        // Press 'x' to cancel — should also process 'x' as a normal key (no-op)
6746        handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
6747        assert!(!state.widget_toggle_mode);
6748        assert!(state.widgets.price_chart); // unchanged
6749    }
6750
6751    #[test]
6752    fn test_keybinding_w_toggle_multiple_widgets() {
6753        let mut state = create_populated_state();
6754
6755        // Toggle widget 2 (volume_chart)
6756        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6757        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
6758        assert!(!state.widgets.volume_chart);
6759
6760        // Toggle widget 4 (metrics_panel)
6761        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6762        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
6763        assert!(!state.widgets.metrics_panel);
6764
6765        // Toggle widget 5 (activity_log)
6766        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6767        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
6768        assert!(!state.widgets.activity_log);
6769    }
6770
6771    #[test]
6772    fn test_monitor_config_serde_roundtrip() {
6773        let config = MonitorConfig {
6774            layout: LayoutPreset::ChartFocus,
6775            refresh_seconds: 5,
6776            widgets: WidgetVisibility {
6777                price_chart: true,
6778                volume_chart: false,
6779                buy_sell_pressure: true,
6780                metrics_panel: false,
6781                activity_log: true,
6782                holder_count: true,
6783                liquidity_depth: true,
6784            },
6785            scale: ScaleMode::Log,
6786            color_scheme: ColorScheme::BlueOrange,
6787            alerts: AlertConfig::default(),
6788            export: ExportConfig::default(),
6789            auto_pause_on_input: false,
6790            venue: None,
6791        };
6792
6793        let yaml = serde_yaml::to_string(&config).unwrap();
6794        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
6795        assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
6796        assert_eq!(parsed.refresh_seconds, 5);
6797        assert!(parsed.widgets.price_chart);
6798        assert!(!parsed.widgets.volume_chart);
6799        assert!(parsed.widgets.buy_sell_pressure);
6800        assert!(!parsed.widgets.metrics_panel);
6801        assert!(parsed.widgets.activity_log);
6802    }
6803
6804    #[test]
6805    fn test_monitor_config_serde_kebab_case() {
6806        let yaml = r#"
6807layout: chart-focus
6808refresh_seconds: 15
6809widgets:
6810  price_chart: true
6811  volume_chart: true
6812  buy_sell_pressure: false
6813  metrics_panel: true
6814  activity_log: false
6815"#;
6816        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6817        assert_eq!(config.layout, LayoutPreset::ChartFocus);
6818        assert_eq!(config.refresh_seconds, 15);
6819        assert!(!config.widgets.buy_sell_pressure);
6820        assert!(!config.widgets.activity_log);
6821    }
6822
6823    #[test]
6824    fn test_monitor_config_serde_default_missing_fields() {
6825        let yaml = "layout: feed\n";
6826        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6827        assert_eq!(config.layout, LayoutPreset::Feed);
6828        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6829        assert!(config.widgets.price_chart); // defaults
6830    }
6831
6832    #[test]
6833    fn test_state_apply_config() {
6834        let mut state = create_populated_state();
6835        let config = MonitorConfig {
6836            layout: LayoutPreset::Feed,
6837            refresh_seconds: 5,
6838            widgets: WidgetVisibility {
6839                price_chart: false,
6840                volume_chart: true,
6841                buy_sell_pressure: true,
6842                metrics_panel: false,
6843                activity_log: true,
6844                holder_count: true,
6845                liquidity_depth: true,
6846            },
6847            scale: ScaleMode::Log,
6848            color_scheme: ColorScheme::Monochrome,
6849            alerts: AlertConfig::default(),
6850            export: ExportConfig::default(),
6851            auto_pause_on_input: false,
6852            venue: None,
6853        };
6854        state.apply_config(&config);
6855        assert_eq!(state.layout, LayoutPreset::Feed);
6856        assert!(!state.widgets.price_chart);
6857        assert!(!state.widgets.metrics_panel);
6858        assert_eq!(state.refresh_rate, Duration::from_secs(5));
6859    }
6860
6861    #[test]
6862    fn test_layout_all_widgets_hidden_dashboard() {
6863        let area = Rect::new(0, 0, 120, 40);
6864        let vis = WidgetVisibility {
6865            price_chart: false,
6866            volume_chart: false,
6867            buy_sell_pressure: false,
6868            metrics_panel: false,
6869            activity_log: false,
6870            holder_count: false,
6871            liquidity_depth: false,
6872        };
6873        let areas = layout_dashboard(area, &vis);
6874        assert!(areas.price_chart.is_none());
6875        assert!(areas.volume_chart.is_none());
6876        assert!(areas.buy_sell_gauge.is_none());
6877        assert!(areas.metrics_panel.is_none());
6878        assert!(areas.activity_feed.is_none());
6879    }
6880
6881    #[test]
6882    fn test_ui_render_with_hidden_widgets() {
6883        let mut terminal = create_test_terminal();
6884        let mut state = create_populated_state();
6885        state.auto_layout = false;
6886        state.widgets.price_chart = false;
6887        state.widgets.volume_chart = false;
6888        terminal.draw(|f| ui(f, &mut state)).unwrap();
6889    }
6890
6891    #[test]
6892    fn test_ui_render_widget_toggle_mode_footer() {
6893        let mut terminal = create_test_terminal();
6894        let mut state = create_populated_state();
6895        state.auto_layout = false;
6896        state.widget_toggle_mode = true;
6897        terminal.draw(|f| ui(f, &mut state)).unwrap();
6898    }
6899
6900    #[test]
6901    fn test_monitor_state_new_has_layout_fields() {
6902        let token_data = create_test_token_data();
6903        let state = MonitorState::new(&token_data, "ethereum");
6904        assert_eq!(state.layout, LayoutPreset::Dashboard);
6905        assert!(state.auto_layout);
6906        assert!(!state.widget_toggle_mode);
6907        assert_eq!(state.widgets.visible_count(), 5);
6908    }
6909
6910    // ========================================================================
6911    // Phase 6: Data Source Integration tests
6912    // ========================================================================
6913
6914    #[test]
6915    fn test_monitor_state_has_holder_count_field() {
6916        let token_data = create_test_token_data();
6917        let state = MonitorState::new(&token_data, "ethereum");
6918        assert_eq!(state.holder_count, None);
6919        assert!(state.liquidity_pairs.is_empty());
6920        assert_eq!(state.holder_fetch_counter, 0);
6921    }
6922
6923    #[test]
6924    fn test_liquidity_pairs_extracted_on_update() {
6925        let mut token_data = create_test_token_data();
6926        token_data.pairs = vec![
6927            crate::chains::DexPair {
6928                dex_name: "Uniswap V3".to_string(),
6929                pair_address: "0xpair1".to_string(),
6930                base_token: "TEST".to_string(),
6931                quote_token: "WETH".to_string(),
6932                price_usd: 1.0,
6933                volume_24h: 500_000.0,
6934                liquidity_usd: 250_000.0,
6935                price_change_24h: 5.0,
6936                buys_24h: 50,
6937                sells_24h: 25,
6938                buys_6h: 10,
6939                sells_6h: 5,
6940                buys_1h: 3,
6941                sells_1h: 2,
6942                pair_created_at: None,
6943                url: None,
6944            },
6945            crate::chains::DexPair {
6946                dex_name: "SushiSwap".to_string(),
6947                pair_address: "0xpair2".to_string(),
6948                base_token: "TEST".to_string(),
6949                quote_token: "USDC".to_string(),
6950                price_usd: 1.0,
6951                volume_24h: 300_000.0,
6952                liquidity_usd: 150_000.0,
6953                price_change_24h: 3.0,
6954                buys_24h: 30,
6955                sells_24h: 15,
6956                buys_6h: 8,
6957                sells_6h: 4,
6958                buys_1h: 2,
6959                sells_1h: 1,
6960                pair_created_at: None,
6961                url: None,
6962            },
6963        ];
6964
6965        let mut state = MonitorState::new(&token_data, "ethereum");
6966        state.update(&token_data);
6967
6968        assert_eq!(state.liquidity_pairs.len(), 2);
6969        assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
6970        assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
6971    }
6972
6973    #[test]
6974    fn test_render_liquidity_depth_no_panic() {
6975        let mut terminal = create_test_terminal();
6976        let mut state = create_populated_state();
6977        state.liquidity_pairs = vec![
6978            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
6979            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
6980        ];
6981        terminal
6982            .draw(|f| render_liquidity_depth(f, f.area(), &state))
6983            .unwrap();
6984    }
6985
6986    #[test]
6987    fn test_render_liquidity_depth_empty() {
6988        let mut terminal = create_test_terminal();
6989        let state = create_populated_state();
6990        terminal
6991            .draw(|f| render_liquidity_depth(f, f.area(), &state))
6992            .unwrap();
6993    }
6994
6995    #[test]
6996    fn test_render_metrics_with_holder_count() {
6997        let mut terminal = create_test_terminal();
6998        let mut state = create_populated_state();
6999        state.holder_count = Some(42_000);
7000        terminal
7001            .draw(|f| render_metrics_panel(f, f.area(), &state))
7002            .unwrap();
7003    }
7004
7005    // ========================================================================
7006    // Phase 7: Alert System tests
7007    // ========================================================================
7008
7009    #[test]
7010    fn test_alert_config_default() {
7011        let config = AlertConfig::default();
7012        assert!(config.price_min.is_none());
7013        assert!(config.price_max.is_none());
7014        assert!(config.whale_min_usd.is_none());
7015        assert!(config.volume_spike_threshold_pct.is_none());
7016    }
7017
7018    #[test]
7019    fn test_alert_price_min_triggers() {
7020        let token_data = create_test_token_data();
7021        let mut state = MonitorState::new(&token_data, "ethereum");
7022        state.alerts.price_min = Some(2.0); // Price is 1.0, below min of 2.0
7023        state.update(&token_data);
7024        assert!(
7025            !state.active_alerts.is_empty(),
7026            "Should have price-min alert"
7027        );
7028        assert!(state.active_alerts[0].message.contains("below min"));
7029    }
7030
7031    #[test]
7032    fn test_alert_price_max_triggers() {
7033        let mut token_data = create_test_token_data();
7034        token_data.price_usd = 100.0;
7035        let mut state = MonitorState::new(&token_data, "ethereum");
7036        state.alerts.price_max = Some(50.0); // Price 100.0 above max of 50.0
7037        state.update(&token_data);
7038        assert!(
7039            !state.active_alerts.is_empty(),
7040            "Should have price-max alert"
7041        );
7042        assert!(state.active_alerts[0].message.contains("above max"));
7043    }
7044
7045    #[test]
7046    fn test_alert_no_trigger_within_bounds() {
7047        let token_data = create_test_token_data();
7048        let mut state = MonitorState::new(&token_data, "ethereum");
7049        state.alerts.price_min = Some(0.5); // Price 1.0 is above min
7050        state.alerts.price_max = Some(2.0); // Price 1.0 is below max
7051        state.update(&token_data);
7052        assert!(
7053            state.active_alerts.is_empty(),
7054            "Should have no alerts when price is within bounds"
7055        );
7056    }
7057
7058    #[test]
7059    fn test_alert_volume_spike_triggers() {
7060        let token_data = create_test_token_data();
7061        let mut state = MonitorState::new(&token_data, "ethereum");
7062        state.alerts.volume_spike_threshold_pct = Some(10.0);
7063        state.volume_avg = 500_000.0; // Average volume is 500K
7064
7065        // Token data has volume_24h of 1M, which is +100% vs avg — should trigger
7066        state.update(&token_data);
7067        let spike_alerts: Vec<_> = state
7068            .active_alerts
7069            .iter()
7070            .filter(|a| a.message.contains("spike"))
7071            .collect();
7072        assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
7073    }
7074
7075    #[test]
7076    fn test_alert_flash_timer_set() {
7077        let token_data = create_test_token_data();
7078        let mut state = MonitorState::new(&token_data, "ethereum");
7079        state.alerts.price_min = Some(2.0);
7080        state.update(&token_data);
7081        assert!(state.alert_flash_until.is_some());
7082    }
7083
7084    #[test]
7085    fn test_render_alert_overlay_no_panic() {
7086        let mut terminal = create_test_terminal();
7087        let mut state = create_populated_state();
7088        state.active_alerts.push(ActiveAlert {
7089            message: "⚠ Test alert".to_string(),
7090            triggered_at: Instant::now(),
7091        });
7092        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7093        terminal
7094            .draw(|f| render_alert_overlay(f, f.area(), &state))
7095            .unwrap();
7096    }
7097
7098    #[test]
7099    fn test_render_alert_overlay_empty() {
7100        let mut terminal = create_test_terminal();
7101        let state = create_populated_state();
7102        terminal
7103            .draw(|f| render_alert_overlay(f, f.area(), &state))
7104            .unwrap();
7105    }
7106
7107    #[test]
7108    fn test_alert_config_serde_roundtrip() {
7109        let config = AlertConfig {
7110            price_min: Some(0.5),
7111            price_max: Some(2.0),
7112            whale_min_usd: Some(10_000.0),
7113            volume_spike_threshold_pct: Some(50.0),
7114        };
7115        let yaml = serde_yaml::to_string(&config).unwrap();
7116        let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
7117        assert_eq!(parsed.price_min, Some(0.5));
7118        assert_eq!(parsed.price_max, Some(2.0));
7119        assert_eq!(parsed.whale_min_usd, Some(10_000.0));
7120        assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
7121    }
7122
7123    #[test]
7124    fn test_ui_with_active_alerts() {
7125        let mut terminal = create_test_terminal();
7126        let mut state = create_populated_state();
7127        state.active_alerts.push(ActiveAlert {
7128            message: "⚠ Price below min".to_string(),
7129            triggered_at: Instant::now(),
7130        });
7131        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7132        terminal.draw(|f| ui(f, &mut state)).unwrap();
7133    }
7134
7135    // ========================================================================
7136    // Phase 8: CSV Export tests
7137    // ========================================================================
7138
7139    #[test]
7140    fn test_export_config_default() {
7141        let config = ExportConfig::default();
7142        assert!(config.path.is_none());
7143    }
7144
7145    /// Helper to create an export in a temp directory, avoiding race conditions.
7146    fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
7147        use std::sync::atomic::{AtomicU64, Ordering};
7148        static COUNTER: AtomicU64 = AtomicU64::new(0);
7149        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
7150        let dir =
7151            std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
7152        let _ = fs::create_dir_all(&dir);
7153        let filename = format!("{}_test_{}.csv", state.symbol, id);
7154        let path = dir.join(filename);
7155
7156        let mut file = fs::File::create(&path).expect("failed to create export test file");
7157        let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
7158        file.write_all(header.as_bytes())
7159            .expect("failed to write header");
7160        drop(file); // Ensure file is flushed and closed
7161
7162        state.export_path = Some(path.clone());
7163        state.export_active = true;
7164        path
7165    }
7166
7167    #[test]
7168    fn test_export_start_creates_file() {
7169        let token_data = create_test_token_data();
7170        let mut state = MonitorState::new(&token_data, "ethereum");
7171        let path = start_export_in_temp(&mut state);
7172
7173        assert!(state.export_active);
7174        assert!(state.export_path.is_some());
7175        assert!(path.exists(), "Export file should exist");
7176
7177        // Cleanup
7178        let _ = std::fs::remove_file(&path);
7179    }
7180
7181    #[test]
7182    fn test_export_stop() {
7183        let token_data = create_test_token_data();
7184        let mut state = MonitorState::new(&token_data, "ethereum");
7185        let path = start_export_in_temp(&mut state);
7186        state.stop_export();
7187
7188        assert!(!state.export_active);
7189        assert!(state.export_path.is_none());
7190
7191        // Cleanup
7192        let _ = std::fs::remove_file(&path);
7193    }
7194
7195    #[test]
7196    fn test_export_toggle() {
7197        let token_data = create_test_token_data();
7198        let mut state = MonitorState::new(&token_data, "ethereum");
7199
7200        state.toggle_export();
7201        assert!(state.export_active);
7202        let path = state.export_path.clone().unwrap();
7203
7204        state.toggle_export();
7205        assert!(!state.export_active);
7206
7207        // Cleanup
7208        let _ = std::fs::remove_file(path);
7209    }
7210
7211    #[test]
7212    fn test_export_writes_csv_rows() {
7213        let token_data = create_test_token_data();
7214        let mut state = MonitorState::new(&token_data, "ethereum");
7215        let path = start_export_in_temp(&mut state);
7216
7217        // Simulate a few updates
7218        state.update(&token_data);
7219        state.update(&token_data);
7220
7221        let contents = std::fs::read_to_string(&path).unwrap();
7222        let lines: Vec<&str> = contents.lines().collect();
7223
7224        assert!(
7225            lines.len() >= 3,
7226            "Should have header + 2 data rows, got {}",
7227            lines.len()
7228        );
7229        assert!(lines[0].starts_with("timestamp,price_usd"));
7230
7231        // Cleanup
7232        state.stop_export();
7233        let _ = std::fs::remove_file(path);
7234    }
7235
7236    #[test]
7237    fn test_keybinding_e_toggles_export() {
7238        let token_data = create_test_token_data();
7239        let mut state = MonitorState::new(&token_data, "ethereum");
7240
7241        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7242        assert!(state.export_active);
7243        let path = state.export_path.clone().unwrap();
7244
7245        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7246        assert!(!state.export_active);
7247
7248        // Cleanup
7249        let _ = std::fs::remove_file(path);
7250    }
7251
7252    #[test]
7253    fn test_render_footer_with_export_active() {
7254        let mut terminal = create_test_terminal();
7255        let mut state = create_populated_state();
7256        state.export_active = true;
7257        terminal
7258            .draw(|f| render_footer(f, f.area(), &state))
7259            .unwrap();
7260    }
7261
7262    #[test]
7263    fn test_export_config_serde_roundtrip() {
7264        let config = ExportConfig {
7265            path: Some("./my-exports".to_string()),
7266        };
7267        let yaml = serde_yaml::to_string(&config).unwrap();
7268        let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
7269        assert_eq!(parsed.path, Some("./my-exports".to_string()));
7270    }
7271
7272    // ========================================================================
7273    // Phase 9: Auto-Pause tests
7274    // ========================================================================
7275
7276    #[test]
7277    fn test_auto_pause_default_disabled() {
7278        let token_data = create_test_token_data();
7279        let state = MonitorState::new(&token_data, "ethereum");
7280        assert!(!state.auto_pause_on_input);
7281        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7282    }
7283
7284    #[test]
7285    fn test_auto_pause_blocks_refresh() {
7286        let token_data = create_test_token_data();
7287        let mut state = MonitorState::new(&token_data, "ethereum");
7288        state.auto_pause_on_input = true;
7289        state.refresh_rate = Duration::from_secs(1);
7290
7291        // Simulate fresh input
7292        state.last_input_at = Instant::now();
7293        state.last_update = Instant::now() - Duration::from_secs(10); // Long overdue
7294
7295        // Auto-pause should block refresh since we just had input
7296        assert!(!state.should_refresh());
7297    }
7298
7299    #[test]
7300    fn test_auto_pause_allows_refresh_after_timeout() {
7301        let token_data = create_test_token_data();
7302        let mut state = MonitorState::new(&token_data, "ethereum");
7303        state.auto_pause_on_input = true;
7304        state.refresh_rate = Duration::from_secs(1);
7305        state.auto_pause_timeout = Duration::from_millis(1); // Very short timeout
7306
7307        // Simulate old input (long ago)
7308        state.last_input_at = Instant::now() - Duration::from_secs(10);
7309        state.last_update = Instant::now() - Duration::from_secs(10);
7310
7311        // Should allow refresh since input was long ago
7312        assert!(state.should_refresh());
7313    }
7314
7315    #[test]
7316    fn test_auto_pause_disabled_does_not_block() {
7317        let token_data = create_test_token_data();
7318        let mut state = MonitorState::new(&token_data, "ethereum");
7319        state.auto_pause_on_input = false;
7320        state.refresh_rate = Duration::from_secs(1);
7321
7322        state.last_input_at = Instant::now(); // Fresh input
7323        state.last_update = Instant::now() - Duration::from_secs(10);
7324
7325        // Should still refresh because auto-pause is disabled
7326        assert!(state.should_refresh());
7327    }
7328
7329    #[test]
7330    fn test_is_auto_paused() {
7331        let token_data = create_test_token_data();
7332        let mut state = MonitorState::new(&token_data, "ethereum");
7333
7334        // Not auto-paused when disabled
7335        state.auto_pause_on_input = false;
7336        state.last_input_at = Instant::now();
7337        assert!(!state.is_auto_paused());
7338
7339        // Auto-paused when enabled and input is recent
7340        state.auto_pause_on_input = true;
7341        state.last_input_at = Instant::now();
7342        assert!(state.is_auto_paused());
7343
7344        // Not auto-paused when input is old
7345        state.last_input_at = Instant::now() - Duration::from_secs(10);
7346        assert!(!state.is_auto_paused());
7347    }
7348
7349    #[test]
7350    fn test_keybinding_shift_p_toggles_auto_pause() {
7351        let token_data = create_test_token_data();
7352        let mut state = MonitorState::new(&token_data, "ethereum");
7353        assert!(!state.auto_pause_on_input);
7354
7355        let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
7356        handle_key_event_on_state(shift_p, &mut state);
7357        assert!(state.auto_pause_on_input);
7358
7359        handle_key_event_on_state(shift_p, &mut state);
7360        assert!(!state.auto_pause_on_input);
7361    }
7362
7363    #[test]
7364    fn test_keybinding_updates_last_input_at() {
7365        let token_data = create_test_token_data();
7366        let mut state = MonitorState::new(&token_data, "ethereum");
7367
7368        // Set last_input_at to the past
7369        state.last_input_at = Instant::now() - Duration::from_secs(60);
7370        let old_input = state.last_input_at;
7371
7372        // Any key event should update last_input_at
7373        handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
7374        assert!(state.last_input_at > old_input);
7375    }
7376
7377    #[test]
7378    fn test_render_footer_auto_paused() {
7379        let mut terminal = create_test_terminal();
7380        let mut state = create_populated_state();
7381        state.auto_pause_on_input = true;
7382        state.last_input_at = Instant::now(); // Recent input -> auto-paused
7383        terminal
7384            .draw(|f| render_footer(f, f.area(), &state))
7385            .unwrap();
7386    }
7387
7388    #[test]
7389    fn test_config_auto_pause_applied() {
7390        let mut state = create_populated_state();
7391        let config = MonitorConfig {
7392            auto_pause_on_input: true,
7393            ..MonitorConfig::default()
7394        };
7395        state.apply_config(&config);
7396        assert!(state.auto_pause_on_input);
7397    }
7398
7399    // ========================================================================
7400    // Combined full-UI tests for new features
7401    // ========================================================================
7402
7403    #[test]
7404    fn test_ui_render_all_layouts_with_alerts_and_export() {
7405        for preset in &[
7406            LayoutPreset::Dashboard,
7407            LayoutPreset::ChartFocus,
7408            LayoutPreset::Feed,
7409            LayoutPreset::Compact,
7410        ] {
7411            let mut terminal = create_test_terminal();
7412            let mut state = create_populated_state();
7413            state.layout = *preset;
7414            state.auto_layout = false;
7415            state.export_active = true;
7416            state.active_alerts.push(ActiveAlert {
7417                message: "⚠ Test alert".to_string(),
7418                triggered_at: Instant::now(),
7419            });
7420            terminal.draw(|f| ui(f, &mut state)).unwrap();
7421        }
7422    }
7423
7424    #[test]
7425    fn test_ui_render_with_liquidity_data() {
7426        let mut terminal = create_test_terminal();
7427        let mut state = create_populated_state();
7428        state.liquidity_pairs = vec![
7429            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
7430            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
7431        ];
7432        terminal.draw(|f| ui(f, &mut state)).unwrap();
7433    }
7434
7435    #[test]
7436    fn test_monitor_config_full_serde_roundtrip() {
7437        let config = MonitorConfig {
7438            layout: LayoutPreset::Dashboard,
7439            refresh_seconds: 10,
7440            widgets: WidgetVisibility::default(),
7441            scale: ScaleMode::Log,
7442            color_scheme: ColorScheme::BlueOrange,
7443            alerts: AlertConfig {
7444                price_min: Some(0.5),
7445                price_max: Some(10.0),
7446                whale_min_usd: Some(50_000.0),
7447                volume_spike_threshold_pct: Some(100.0),
7448            },
7449            export: ExportConfig {
7450                path: Some("./exports".to_string()),
7451            },
7452            auto_pause_on_input: true,
7453            venue: Some("binance".to_string()),
7454        };
7455
7456        let yaml = serde_yaml::to_string(&config).unwrap();
7457        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
7458        assert_eq!(parsed.layout, LayoutPreset::Dashboard);
7459        assert_eq!(parsed.refresh_seconds, 10);
7460        assert_eq!(parsed.venue, Some("binance".to_string()));
7461        assert_eq!(parsed.alerts.price_min, Some(0.5));
7462        assert_eq!(parsed.alerts.price_max, Some(10.0));
7463        assert_eq!(parsed.export.path, Some("./exports".to_string()));
7464        assert!(parsed.auto_pause_on_input);
7465    }
7466
7467    #[test]
7468    fn test_monitor_config_serde_defaults_for_new_fields() {
7469        // Only specify old fields — new fields should default
7470        let yaml = r#"
7471layout: dashboard
7472refresh_seconds: 5
7473"#;
7474        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
7475        assert!(config.alerts.price_min.is_none());
7476        assert!(config.export.path.is_none());
7477        assert!(!config.auto_pause_on_input);
7478    }
7479
7480    #[test]
7481    fn test_quit_stops_export() {
7482        let token_data = create_test_token_data();
7483        let mut state = MonitorState::new(&token_data, "ethereum");
7484        let path = start_export_in_temp(&mut state);
7485
7486        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
7487        assert!(exit);
7488        assert!(!state.export_active);
7489
7490        // Cleanup
7491        let _ = std::fs::remove_file(path);
7492    }
7493
7494    #[test]
7495    fn test_monitor_state_new_has_alert_export_autopause_fields() {
7496        let token_data = create_test_token_data();
7497        let state = MonitorState::new(&token_data, "ethereum");
7498
7499        // Alert fields
7500        assert!(state.active_alerts.is_empty());
7501        assert!(state.alert_flash_until.is_none());
7502        assert!(state.alerts.price_min.is_none());
7503
7504        // Export fields
7505        assert!(!state.export_active);
7506        assert!(state.export_path.is_none());
7507
7508        // Auto-pause fields
7509        assert!(!state.auto_pause_on_input);
7510        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7511    }
7512
7513    // ========================================================================
7514    // Coverage gap: Serde round-trip tests for enums
7515    // ========================================================================
7516
7517    #[test]
7518    fn test_scale_mode_serde_roundtrip() {
7519        for mode in &[ScaleMode::Linear, ScaleMode::Log] {
7520            let yaml = serde_yaml::to_string(mode).unwrap();
7521            let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
7522            assert_eq!(&parsed, mode);
7523        }
7524    }
7525
7526    #[test]
7527    fn test_scale_mode_serde_kebab_case() {
7528        let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
7529        assert_eq!(parsed, ScaleMode::Linear);
7530        let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
7531        assert_eq!(parsed, ScaleMode::Log);
7532    }
7533
7534    #[test]
7535    fn test_scale_mode_toggle() {
7536        assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
7537        assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
7538    }
7539
7540    #[test]
7541    fn test_scale_mode_label() {
7542        assert_eq!(ScaleMode::Linear.label(), "Lin");
7543        assert_eq!(ScaleMode::Log.label(), "Log");
7544    }
7545
7546    #[test]
7547    fn test_color_scheme_serde_roundtrip() {
7548        for scheme in &[
7549            ColorScheme::GreenRed,
7550            ColorScheme::BlueOrange,
7551            ColorScheme::Monochrome,
7552        ] {
7553            let yaml = serde_yaml::to_string(scheme).unwrap();
7554            let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
7555            assert_eq!(&parsed, scheme);
7556        }
7557    }
7558
7559    #[test]
7560    fn test_color_scheme_serde_kebab_case() {
7561        let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
7562        assert_eq!(parsed, ColorScheme::GreenRed);
7563        let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
7564        assert_eq!(parsed, ColorScheme::BlueOrange);
7565        let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
7566        assert_eq!(parsed, ColorScheme::Monochrome);
7567    }
7568
7569    #[test]
7570    fn test_color_scheme_cycle() {
7571        assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
7572        assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
7573        assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
7574    }
7575
7576    #[test]
7577    fn test_color_scheme_label() {
7578        assert_eq!(ColorScheme::GreenRed.label(), "G/R");
7579        assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
7580        assert_eq!(ColorScheme::Monochrome.label(), "Mono");
7581    }
7582
7583    #[test]
7584    fn test_color_palette_fields_populated() {
7585        // Verify each palette has distinct meaningful values
7586        for scheme in &[
7587            ColorScheme::GreenRed,
7588            ColorScheme::BlueOrange,
7589            ColorScheme::Monochrome,
7590        ] {
7591            let pal = scheme.palette();
7592            // up and down colors should differ (visually distinct)
7593            assert_ne!(
7594                format!("{:?}", pal.up),
7595                format!("{:?}", pal.down),
7596                "Up/down should differ for {:?}",
7597                scheme
7598            );
7599        }
7600    }
7601
7602    #[test]
7603    fn test_layout_preset_serde_roundtrip() {
7604        for preset in &[
7605            LayoutPreset::Dashboard,
7606            LayoutPreset::ChartFocus,
7607            LayoutPreset::Feed,
7608            LayoutPreset::Compact,
7609        ] {
7610            let yaml = serde_yaml::to_string(preset).unwrap();
7611            let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
7612            assert_eq!(&parsed, preset);
7613        }
7614    }
7615
7616    #[test]
7617    fn test_layout_preset_serde_kebab_case() {
7618        let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
7619        assert_eq!(parsed, LayoutPreset::Dashboard);
7620        let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
7621        assert_eq!(parsed, LayoutPreset::ChartFocus);
7622        let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
7623        assert_eq!(parsed, LayoutPreset::Feed);
7624        let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
7625        assert_eq!(parsed, LayoutPreset::Compact);
7626    }
7627
7628    #[test]
7629    fn test_widget_visibility_serde_roundtrip() {
7630        let vis = WidgetVisibility {
7631            price_chart: false,
7632            volume_chart: true,
7633            buy_sell_pressure: false,
7634            metrics_panel: true,
7635            activity_log: false,
7636            holder_count: false,
7637            liquidity_depth: true,
7638        };
7639        let yaml = serde_yaml::to_string(&vis).unwrap();
7640        let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
7641        assert!(!parsed.price_chart);
7642        assert!(parsed.volume_chart);
7643        assert!(!parsed.buy_sell_pressure);
7644        assert!(parsed.metrics_panel);
7645        assert!(!parsed.activity_log);
7646        assert!(!parsed.holder_count);
7647        assert!(parsed.liquidity_depth);
7648    }
7649
7650    #[test]
7651    fn test_data_point_serde_roundtrip() {
7652        let dp = DataPoint {
7653            timestamp: 1700000000.5,
7654            value: 42.123456,
7655            is_real: true,
7656        };
7657        let json = serde_json::to_string(&dp).unwrap();
7658        let parsed: DataPoint = serde_json::from_str(&json).unwrap();
7659        assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
7660        assert!((parsed.value - dp.value).abs() < 0.001);
7661        assert_eq!(parsed.is_real, dp.is_real);
7662    }
7663
7664    // ========================================================================
7665    // Coverage gap: Key handler tests for scale and color scheme
7666    // ========================================================================
7667
7668    #[test]
7669    fn test_handle_key_scale_toggle_s() {
7670        let token_data = create_test_token_data();
7671        let mut state = MonitorState::new(&token_data, "ethereum");
7672        assert_eq!(state.scale_mode, ScaleMode::Linear);
7673
7674        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7675        assert_eq!(state.scale_mode, ScaleMode::Log);
7676
7677        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7678        assert_eq!(state.scale_mode, ScaleMode::Linear);
7679    }
7680
7681    #[test]
7682    fn test_handle_key_color_scheme_cycle_slash() {
7683        let token_data = create_test_token_data();
7684        let mut state = MonitorState::new(&token_data, "ethereum");
7685        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7686
7687        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7688        assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
7689
7690        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7691        assert_eq!(state.color_scheme, ColorScheme::Monochrome);
7692
7693        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7694        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7695    }
7696
7697    // ========================================================================
7698    // Coverage gap: Volume profile chart render tests
7699    // ========================================================================
7700
7701    #[test]
7702    fn test_render_volume_profile_chart_no_panic() {
7703        let mut terminal = create_test_terminal();
7704        let state = create_populated_state();
7705        terminal
7706            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7707            .unwrap();
7708    }
7709
7710    #[test]
7711    fn test_render_volume_profile_chart_empty_data() {
7712        let mut terminal = create_test_terminal();
7713        let token_data = create_test_token_data();
7714        let mut state = MonitorState::new(&token_data, "ethereum");
7715        state.price_history.clear();
7716        state.volume_history.clear();
7717        terminal
7718            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7719            .unwrap();
7720    }
7721
7722    #[test]
7723    fn test_render_volume_profile_chart_single_price() {
7724        // When all prices are identical, there's no range -> "no price range" path
7725        let mut terminal = create_test_terminal();
7726        let mut token_data = create_test_token_data();
7727        token_data.price_usd = 1.0;
7728        let mut state = MonitorState::new(&token_data, "ethereum");
7729        // Clear and add identical-price data points
7730        state.price_history.clear();
7731        state.volume_history.clear();
7732        let now = chrono::Utc::now().timestamp() as f64;
7733        for i in 0..5 {
7734            state.price_history.push_back(DataPoint {
7735                timestamp: now - (5.0 - i as f64) * 60.0,
7736                value: 1.0, // all same price
7737                is_real: true,
7738            });
7739            state.volume_history.push_back(DataPoint {
7740                timestamp: now - (5.0 - i as f64) * 60.0,
7741                value: 1000.0,
7742                is_real: true,
7743            });
7744        }
7745        terminal
7746            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7747            .unwrap();
7748    }
7749
7750    #[test]
7751    fn test_render_volume_profile_chart_narrow_terminal() {
7752        let backend = TestBackend::new(30, 15);
7753        let mut terminal = Terminal::new(backend).unwrap();
7754        let state = create_populated_state();
7755        terminal
7756            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7757            .unwrap();
7758    }
7759
7760    // ========================================================================
7761    // Coverage gap: Log scale rendering tests
7762    // ========================================================================
7763
7764    #[test]
7765    fn test_render_price_chart_log_scale() {
7766        let mut terminal = create_test_terminal();
7767        let mut state = create_populated_state();
7768        state.scale_mode = ScaleMode::Log;
7769        terminal
7770            .draw(|f| render_price_chart(f, f.area(), &state))
7771            .unwrap();
7772    }
7773
7774    #[test]
7775    fn test_render_candlestick_chart_log_scale() {
7776        let mut terminal = create_test_terminal();
7777        let mut state = create_populated_state();
7778        state.scale_mode = ScaleMode::Log;
7779        state.chart_mode = ChartMode::Candlestick;
7780        terminal
7781            .draw(|f| render_candlestick_chart(f, f.area(), &state))
7782            .unwrap();
7783    }
7784
7785    #[test]
7786    fn test_render_price_chart_log_scale_zero_price() {
7787        // Verify log scale handles zero/near-zero prices safely
7788        let mut terminal = create_test_terminal();
7789        let mut token_data = create_test_token_data();
7790        token_data.price_usd = 0.0001;
7791        let mut state = MonitorState::new(&token_data, "ethereum");
7792        state.scale_mode = ScaleMode::Log;
7793        for i in 0..10 {
7794            let mut data = token_data.clone();
7795            data.price_usd = 0.0001 + (i as f64 * 0.00001);
7796            state.update(&data);
7797        }
7798        terminal
7799            .draw(|f| render_price_chart(f, f.area(), &state))
7800            .unwrap();
7801    }
7802
7803    // ========================================================================
7804    // Coverage gap: Color scheme rendering tests
7805    // ========================================================================
7806
7807    #[test]
7808    fn test_render_ui_with_all_color_schemes() {
7809        for scheme in &[
7810            ColorScheme::GreenRed,
7811            ColorScheme::BlueOrange,
7812            ColorScheme::Monochrome,
7813        ] {
7814            let mut terminal = create_test_terminal();
7815            let mut state = create_populated_state();
7816            state.color_scheme = *scheme;
7817            terminal.draw(|f| ui(f, &mut state)).unwrap();
7818        }
7819    }
7820
7821    #[test]
7822    fn test_render_volume_chart_all_color_schemes() {
7823        for scheme in &[
7824            ColorScheme::GreenRed,
7825            ColorScheme::BlueOrange,
7826            ColorScheme::Monochrome,
7827        ] {
7828            let mut terminal = create_test_terminal();
7829            let mut state = create_populated_state();
7830            state.color_scheme = *scheme;
7831            terminal
7832                .draw(|f| render_volume_chart(f, f.area(), &state))
7833                .unwrap();
7834        }
7835    }
7836
7837    // ========================================================================
7838    // Coverage gap: Activity feed dedicated render tests
7839    // ========================================================================
7840
7841    #[test]
7842    fn test_render_activity_feed_no_panic() {
7843        let mut terminal = create_test_terminal();
7844        let mut state = create_populated_state();
7845        for i in 0..5 {
7846            state.log_messages.push_back(format!("Event {}", i));
7847        }
7848        terminal
7849            .draw(|f| render_activity_feed(f, f.area(), &mut state))
7850            .unwrap();
7851    }
7852
7853    #[test]
7854    fn test_render_activity_feed_empty_log() {
7855        let mut terminal = create_test_terminal();
7856        let token_data = create_test_token_data();
7857        let mut state = MonitorState::new(&token_data, "ethereum");
7858        state.log_messages.clear();
7859        terminal
7860            .draw(|f| render_activity_feed(f, f.area(), &mut state))
7861            .unwrap();
7862    }
7863
7864    #[test]
7865    fn test_render_activity_feed_with_selection() {
7866        let mut terminal = create_test_terminal();
7867        let mut state = create_populated_state();
7868        for i in 0..10 {
7869            state.log_messages.push_back(format!("Event {}", i));
7870        }
7871        state.scroll_log_down();
7872        state.scroll_log_down();
7873        state.scroll_log_down();
7874        terminal
7875            .draw(|f| render_activity_feed(f, f.area(), &mut state))
7876            .unwrap();
7877    }
7878
7879    // ========================================================================
7880    // Coverage gap: Alert edge cases
7881    // ========================================================================
7882
7883    #[test]
7884    fn test_alert_whale_zero_transactions() {
7885        let mut token_data = create_test_token_data();
7886        token_data.total_buys_24h = 0;
7887        token_data.total_sells_24h = 0;
7888        let mut state = MonitorState::new(&token_data, "ethereum");
7889        state.alerts.whale_min_usd = Some(100.0);
7890        state.update(&token_data);
7891        // With zero total txs, whale detection should NOT fire
7892        let whale_alerts: Vec<_> = state
7893            .active_alerts
7894            .iter()
7895            .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
7896            .collect();
7897        assert!(
7898            whale_alerts.is_empty(),
7899            "Whale alert should not fire with zero transactions"
7900        );
7901    }
7902
7903    #[test]
7904    fn test_alert_multiple_simultaneous() {
7905        let mut token_data = create_test_token_data();
7906        token_data.price_usd = 0.1; // below min
7907        let mut state = MonitorState::new(&token_data, "ethereum");
7908        state.alerts.price_min = Some(0.5); // will fire: price 0.1 < 0.5
7909        state.alerts.price_max = Some(0.05); // will fire: price 0.1 > 0.05
7910        state.alerts.volume_spike_threshold_pct = Some(1.0);
7911        state.volume_avg = 100.0; // volume_24h 1M vs avg 100 => huge spike
7912
7913        state.update(&token_data);
7914        // Should have multiple alerts
7915        assert!(
7916            state.active_alerts.len() >= 2,
7917            "Expected multiple alerts, got {}",
7918            state.active_alerts.len()
7919        );
7920    }
7921
7922    #[test]
7923    fn test_alert_clears_on_next_update() {
7924        let token_data = create_test_token_data();
7925        let mut state = MonitorState::new(&token_data, "ethereum");
7926        state.alerts.price_min = Some(2.0); // price 1.0 < 2.0 -> fires
7927        state.update(&token_data);
7928        assert!(!state.active_alerts.is_empty());
7929
7930        // Update with price above min -> should clear
7931        let mut above_min = token_data.clone();
7932        above_min.price_usd = 3.0;
7933        state.alerts.price_min = Some(2.0);
7934        state.update(&above_min);
7935        // check_alerts clears alerts each time and re-evaluates
7936        let price_min_alerts: Vec<_> = state
7937            .active_alerts
7938            .iter()
7939            .filter(|a| a.message.contains("below min"))
7940            .collect();
7941        assert!(
7942            price_min_alerts.is_empty(),
7943            "Price-min alert should clear when price goes above min"
7944        );
7945    }
7946
7947    #[test]
7948    fn test_render_alert_overlay_multiple_alerts() {
7949        let mut terminal = create_test_terminal();
7950        let mut state = create_populated_state();
7951        state.active_alerts.push(ActiveAlert {
7952            message: "⚠ Price below min".to_string(),
7953            triggered_at: Instant::now(),
7954        });
7955        state.active_alerts.push(ActiveAlert {
7956            message: "🐋 Whale detected".to_string(),
7957            triggered_at: Instant::now(),
7958        });
7959        state.active_alerts.push(ActiveAlert {
7960            message: "⚠ Volume spike".to_string(),
7961            triggered_at: Instant::now(),
7962        });
7963        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7964        terminal
7965            .draw(|f| render_alert_overlay(f, f.area(), &state))
7966            .unwrap();
7967    }
7968
7969    #[test]
7970    fn test_render_alert_overlay_flash_expired() {
7971        let mut terminal = create_test_terminal();
7972        let mut state = create_populated_state();
7973        state.active_alerts.push(ActiveAlert {
7974            message: "⚠ Test".to_string(),
7975            triggered_at: Instant::now(),
7976        });
7977        // Flash timer already expired
7978        state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
7979        terminal
7980            .draw(|f| render_alert_overlay(f, f.area(), &state))
7981            .unwrap();
7982    }
7983
7984    // ========================================================================
7985    // Coverage gap: Liquidity depth edge cases
7986    // ========================================================================
7987
7988    #[test]
7989    fn test_render_liquidity_depth_many_pairs() {
7990        let mut terminal = create_test_terminal();
7991        let mut state = create_populated_state();
7992        // Add many pairs to test height-limiting
7993        for i in 0..20 {
7994            state.liquidity_pairs.push((
7995                format!("TEST/TOKEN{} (DEX{})", i, i),
7996                (100_000.0 + i as f64 * 50_000.0),
7997            ));
7998        }
7999        terminal
8000            .draw(|f| render_liquidity_depth(f, f.area(), &state))
8001            .unwrap();
8002    }
8003
8004    #[test]
8005    fn test_render_liquidity_depth_narrow_terminal() {
8006        let backend = TestBackend::new(30, 10);
8007        let mut terminal = Terminal::new(backend).unwrap();
8008        let mut state = create_populated_state();
8009        state.liquidity_pairs = vec![
8010            ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
8011            ("TEST/USDC (Sushi)".to_string(), 100_000.0),
8012        ];
8013        terminal
8014            .draw(|f| render_liquidity_depth(f, f.area(), &state))
8015            .unwrap();
8016    }
8017
8018    // ========================================================================
8019    // Coverage gap: Metrics panel edge cases
8020    // ========================================================================
8021
8022    #[test]
8023    fn test_render_metrics_panel_holder_count_disabled() {
8024        let mut terminal = create_test_terminal();
8025        let mut state = create_populated_state();
8026        state.holder_count = Some(42_000);
8027        state.widgets.holder_count = false; // disabled
8028        terminal
8029            .draw(|f| render_metrics_panel(f, f.area(), &state))
8030            .unwrap();
8031    }
8032
8033    #[test]
8034    fn test_render_metrics_panel_sparkline_single_point() {
8035        let mut terminal = create_test_terminal();
8036        let mut token_data = create_test_token_data();
8037        token_data.price_usd = 1.0;
8038        let mut state = MonitorState::new(&token_data, "ethereum");
8039        state.price_history.clear();
8040        state.price_history.push_back(DataPoint {
8041            timestamp: 1.0,
8042            value: 1.0,
8043            is_real: true,
8044        });
8045        terminal
8046            .draw(|f| render_metrics_panel(f, f.area(), &state))
8047            .unwrap();
8048    }
8049
8050    // ========================================================================
8051    // Coverage gap: Buy/sell gauge edge cases
8052    // ========================================================================
8053
8054    #[test]
8055    fn test_render_buy_sell_gauge_tiny_area() {
8056        // Render in a very small area to test zero width/height paths
8057        let backend = TestBackend::new(5, 3);
8058        let mut terminal = Terminal::new(backend).unwrap();
8059        let mut state = create_populated_state();
8060        terminal
8061            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
8062            .unwrap();
8063    }
8064
8065    // ========================================================================
8066    // Coverage gap: Log message queue overflow
8067    // ========================================================================
8068
8069    #[test]
8070    fn test_log_message_queue_overflow() {
8071        let token_data = create_test_token_data();
8072        let mut state = MonitorState::new(&token_data, "ethereum");
8073        // Add more than 10 messages (queue capacity)
8074        for i in 0..20 {
8075            state.toggle_pause(); // each toggle logs a message
8076            let _ = i;
8077        }
8078        assert!(
8079            state.log_messages.len() <= 10,
8080            "Log queue should cap at 10, got {}",
8081            state.log_messages.len()
8082        );
8083    }
8084
8085    // ========================================================================
8086    // Coverage gap: Export CSV row content verification
8087    // ========================================================================
8088
8089    #[test]
8090    fn test_export_writes_csv_row_content_format() {
8091        let token_data = create_test_token_data();
8092        let mut state = MonitorState::new(&token_data, "ethereum");
8093        let path = start_export_in_temp(&mut state);
8094
8095        state.update(&token_data);
8096
8097        let contents = std::fs::read_to_string(&path).unwrap();
8098        let lines: Vec<&str> = contents.lines().collect();
8099        assert!(lines.len() >= 2);
8100
8101        // Verify header
8102        assert_eq!(
8103            lines[0],
8104            "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
8105        );
8106
8107        // Verify data row has correct number of columns
8108        let data_cols: Vec<&str> = lines[1].split(',').collect();
8109        assert_eq!(
8110            data_cols.len(),
8111            7,
8112            "Expected 7 CSV columns, got {}",
8113            data_cols.len()
8114        );
8115
8116        // Verify timestamp format (ISO 8601)
8117        assert!(data_cols[0].contains('T'));
8118        assert!(data_cols[0].ends_with('Z'));
8119
8120        // Cleanup
8121        state.stop_export();
8122        let _ = std::fs::remove_file(path);
8123    }
8124
8125    #[test]
8126    fn test_export_writes_csv_row_market_cap_none() {
8127        let mut token_data = create_test_token_data();
8128        token_data.market_cap = None;
8129        let mut state = MonitorState::new(&token_data, "ethereum");
8130        let path = start_export_in_temp(&mut state);
8131
8132        state.update(&token_data);
8133
8134        let contents = std::fs::read_to_string(&path).unwrap();
8135        let lines: Vec<&str> = contents.lines().collect();
8136        assert!(lines.len() >= 2);
8137
8138        // Last column should be empty when market_cap is None
8139        let data_cols: Vec<&str> = lines[1].split(',').collect();
8140        assert_eq!(data_cols.len(), 7);
8141        assert!(
8142            data_cols[6].is_empty(),
8143            "Market cap column should be empty when None"
8144        );
8145
8146        // Cleanup
8147        state.stop_export();
8148        let _ = std::fs::remove_file(path);
8149    }
8150
8151    // ========================================================================
8152    // Coverage gap: Full UI render with log scale + all chart modes
8153    // ========================================================================
8154
8155    #[test]
8156    fn test_ui_render_log_scale_all_chart_modes() {
8157        for mode in &[
8158            ChartMode::Line,
8159            ChartMode::Candlestick,
8160            ChartMode::VolumeProfile,
8161        ] {
8162            let mut terminal = create_test_terminal();
8163            let mut state = create_populated_state();
8164            state.scale_mode = ScaleMode::Log;
8165            state.chart_mode = *mode;
8166            terminal.draw(|f| ui(f, &mut state)).unwrap();
8167        }
8168    }
8169
8170    // ========================================================================
8171    // Coverage gap: Footer rendering edge cases
8172    // ========================================================================
8173
8174    #[test]
8175    fn test_render_footer_widget_toggle_mode_active() {
8176        let mut terminal = create_test_terminal();
8177        let mut state = create_populated_state();
8178        state.widget_toggle_mode = true;
8179        terminal
8180            .draw(|f| render_footer(f, f.area(), &state))
8181            .unwrap();
8182    }
8183
8184    #[test]
8185    fn test_render_footer_all_status_indicators() {
8186        // Test with export + auto-pause + alerts simultaneously
8187        let mut terminal = create_test_terminal();
8188        let mut state = create_populated_state();
8189        state.export_active = true;
8190        state.auto_pause_on_input = true;
8191        state.last_input_at = Instant::now(); // triggers auto-paused display
8192        terminal
8193            .draw(|f| render_footer(f, f.area(), &state))
8194            .unwrap();
8195    }
8196
8197    // ========================================================================
8198    // Coverage gap: Synthetic data generation edge cases
8199    // ========================================================================
8200
8201    #[test]
8202    fn test_generate_synthetic_price_history_zero_price() {
8203        let mut token_data = create_test_token_data();
8204        token_data.price_usd = 0.0;
8205        token_data.price_change_1h = 0.0;
8206        token_data.price_change_6h = 0.0;
8207        token_data.price_change_24h = 0.0;
8208        let state = MonitorState::new(&token_data, "ethereum");
8209        // Should not panic with zero prices
8210        assert!(!state.price_history.is_empty());
8211    }
8212
8213    #[test]
8214    fn test_generate_synthetic_volume_history_zero_volume() {
8215        let mut token_data = create_test_token_data();
8216        token_data.volume_24h = 0.0;
8217        token_data.volume_6h = 0.0;
8218        token_data.volume_1h = 0.0;
8219        let state = MonitorState::new(&token_data, "ethereum");
8220        assert!(!state.volume_history.is_empty());
8221    }
8222
8223    #[test]
8224    fn test_generate_synthetic_order_book() {
8225        let pairs = vec![crate::chains::DexPair {
8226            dex_name: "Uniswap V3".to_string(),
8227            pair_address: "0xabc".to_string(),
8228            base_token: "PUSD".to_string(),
8229            quote_token: "USDT".to_string(),
8230            price_usd: 1.0,
8231            volume_24h: 50_000.0,
8232            liquidity_usd: 200_000.0,
8233            price_change_24h: 0.1,
8234            buys_24h: 100,
8235            sells_24h: 90,
8236            buys_6h: 30,
8237            sells_6h: 25,
8238            buys_1h: 10,
8239            sells_1h: 8,
8240            pair_created_at: None,
8241            url: None,
8242        }];
8243        let book = MonitorState::generate_synthetic_order_book(&pairs, "PUSD", 1.0, 200_000.0);
8244        assert!(book.is_some());
8245        let book = book.unwrap();
8246        assert_eq!(book.pair, "PUSD/USDT");
8247        assert!(!book.asks.is_empty());
8248        assert!(!book.bids.is_empty());
8249        // Asks should be ascending
8250        for w in book.asks.windows(2) {
8251            assert!(w[0].price <= w[1].price);
8252        }
8253        // Bids should be descending
8254        for w in book.bids.windows(2) {
8255            assert!(w[0].price >= w[1].price);
8256        }
8257    }
8258
8259    #[test]
8260    fn test_generate_synthetic_order_book_zero_price() {
8261        let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 0.0, 100_000.0);
8262        assert!(book.is_none());
8263    }
8264
8265    #[test]
8266    fn test_generate_synthetic_order_book_zero_liquidity() {
8267        let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 0.0);
8268        assert!(book.is_none());
8269    }
8270
8271    // ========================================================================
8272    // Coverage gap: Auto-pause with custom timeout
8273    // ========================================================================
8274
8275    #[test]
8276    fn test_auto_pause_custom_timeout() {
8277        let token_data = create_test_token_data();
8278        let mut state = MonitorState::new(&token_data, "ethereum");
8279        state.auto_pause_on_input = true;
8280        state.auto_pause_timeout = Duration::from_secs(10);
8281        state.refresh_rate = Duration::from_secs(1);
8282
8283        // Fresh input with long timeout -> still auto-paused
8284        state.last_input_at = Instant::now();
8285        state.last_update = Instant::now() - Duration::from_secs(5);
8286        assert!(!state.should_refresh()); // within 10s timeout
8287        assert!(state.is_auto_paused());
8288    }
8289
8290    // ========================================================================
8291    // Coverage gap: Price chart with stablecoin flat range
8292    // ========================================================================
8293
8294    #[test]
8295    fn test_render_price_chart_stablecoin_flat_range() {
8296        let mut terminal = create_test_terminal();
8297        let mut token_data = create_test_token_data();
8298        token_data.price_usd = 1.0;
8299        let mut state = MonitorState::new(&token_data, "ethereum");
8300        // Add many points at nearly identical prices (stablecoin)
8301        for i in 0..20 {
8302            let mut data = token_data.clone();
8303            data.price_usd = 1.0 + (i as f64 * 0.000001); // micro variation
8304            state.update(&data);
8305        }
8306        terminal
8307            .draw(|f| render_price_chart(f, f.area(), &state))
8308            .unwrap();
8309    }
8310
8311    // ========================================================================
8312    // Coverage gap: Cache load edge cases
8313    // ========================================================================
8314
8315    #[test]
8316    fn test_load_cache_corrupted_json() {
8317        let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
8318        // Write invalid JSON
8319        let _ = std::fs::write(&path, "not valid json {{{");
8320        let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
8321        assert!(cached.is_none(), "Corrupted JSON should return None");
8322        let _ = std::fs::remove_file(path);
8323    }
8324
8325    #[test]
8326    fn test_load_cache_wrong_token() {
8327        let token_data = create_test_token_data();
8328        let state = MonitorState::new(&token_data, "ethereum");
8329        state.save_cache();
8330
8331        // Try to load with different token address
8332        let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
8333        assert!(
8334            cached.is_none(),
8335            "Loading cache with wrong token address should return None"
8336        );
8337
8338        // Cleanup
8339        let path = MonitorState::cache_path(&token_data.address, "ethereum");
8340        let _ = std::fs::remove_file(path);
8341    }
8342
8343    // ========================================================================
8344    // Integration tests: Mock types and MonitorApp constructor
8345    // ========================================================================
8346
8347    use crate::chains::dex::TokenSearchResult;
8348
8349    /// Mock DEX data source for integration testing.
8350    struct MockDexDataSource {
8351        /// Data returned by `get_token_data`. If `Err`, simulates an API failure.
8352        token_data_result: std::sync::Mutex<Result<DexTokenData>>,
8353    }
8354
8355    impl MockDexDataSource {
8356        fn new(data: DexTokenData) -> Self {
8357            Self {
8358                token_data_result: std::sync::Mutex::new(Ok(data)),
8359            }
8360        }
8361
8362        fn failing(msg: &str) -> Self {
8363            Self {
8364                token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
8365            }
8366        }
8367    }
8368
8369    #[async_trait::async_trait]
8370    impl DexDataSource for MockDexDataSource {
8371        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
8372            self.token_data_result
8373                .lock()
8374                .unwrap()
8375                .as_ref()
8376                .ok()
8377                .map(|d| d.price_usd)
8378        }
8379
8380        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
8381            Some(2000.0)
8382        }
8383
8384        async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
8385            let guard = self.token_data_result.lock().unwrap();
8386            match &*guard {
8387                Ok(data) => Ok(data.clone()),
8388                Err(e) => Err(ScopeError::Api(e.to_string())),
8389            }
8390        }
8391
8392        async fn search_tokens(
8393            &self,
8394            _query: &str,
8395            _chain: Option<&str>,
8396        ) -> Result<Vec<TokenSearchResult>> {
8397            Ok(vec![])
8398        }
8399    }
8400
8401    /// Mock chain client for integration testing.
8402    struct MockChainClient {
8403        holder_count: u64,
8404    }
8405
8406    impl MockChainClient {
8407        fn new(holder_count: u64) -> Self {
8408            Self { holder_count }
8409        }
8410    }
8411
8412    #[async_trait::async_trait]
8413    impl ChainClient for MockChainClient {
8414        fn chain_name(&self) -> &str {
8415            "ethereum"
8416        }
8417        fn native_token_symbol(&self) -> &str {
8418            "ETH"
8419        }
8420        async fn get_balance(&self, _address: &str) -> Result<crate::chains::Balance> {
8421            unimplemented!("not needed for monitor tests")
8422        }
8423        async fn enrich_balance_usd(&self, _balance: &mut crate::chains::Balance) {}
8424        async fn get_transaction(&self, _hash: &str) -> Result<crate::chains::Transaction> {
8425            unimplemented!("not needed for monitor tests")
8426        }
8427        async fn get_transactions(
8428            &self,
8429            _address: &str,
8430            _limit: u32,
8431        ) -> Result<Vec<crate::chains::Transaction>> {
8432            Ok(vec![])
8433        }
8434        async fn get_block_number(&self) -> Result<u64> {
8435            Ok(1000000)
8436        }
8437        async fn get_token_balances(
8438            &self,
8439            _address: &str,
8440        ) -> Result<Vec<crate::chains::TokenBalance>> {
8441            Ok(vec![])
8442        }
8443        async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
8444            Ok(self.holder_count)
8445        }
8446    }
8447
8448    /// Creates a `MonitorApp<TestBackend>` with mock dependencies.
8449    fn create_test_app(
8450        dex: Box<dyn DexDataSource>,
8451        chain_client: Option<Box<dyn ChainClient>>,
8452    ) -> MonitorApp<TestBackend> {
8453        let token_data = create_test_token_data();
8454        let state = MonitorState::new(&token_data, "ethereum");
8455        let backend = TestBackend::new(120, 40);
8456        let terminal = ratatui::Terminal::new(backend).unwrap();
8457        MonitorApp {
8458            terminal,
8459            state,
8460            dex_client: dex,
8461            chain_client,
8462            exchange_client: None,
8463            should_exit: false,
8464            owns_terminal: false,
8465        }
8466    }
8467
8468    fn create_test_app_with_state(
8469        state: MonitorState,
8470        dex: Box<dyn DexDataSource>,
8471        chain_client: Option<Box<dyn ChainClient>>,
8472    ) -> MonitorApp<TestBackend> {
8473        let backend = TestBackend::new(120, 40);
8474        let terminal = ratatui::Terminal::new(backend).unwrap();
8475        MonitorApp {
8476            terminal,
8477            state,
8478            dex_client: dex,
8479            chain_client,
8480            exchange_client: None,
8481            should_exit: false,
8482            owns_terminal: false,
8483        }
8484    }
8485
8486    // ========================================================================
8487    // Integration tests: MonitorApp::handle_key_event
8488    // ========================================================================
8489
8490    #[test]
8491    fn test_app_handle_key_quit_q() {
8492        let data = create_test_token_data();
8493        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8494        assert!(!app.should_exit);
8495        app.handle_key_event(make_key_event(KeyCode::Char('q')));
8496        assert!(app.should_exit);
8497    }
8498
8499    #[test]
8500    fn test_app_handle_key_quit_esc() {
8501        let data = create_test_token_data();
8502        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8503        app.handle_key_event(make_key_event(KeyCode::Esc));
8504        assert!(app.should_exit);
8505    }
8506
8507    #[test]
8508    fn test_app_handle_key_quit_ctrl_c() {
8509        let data = create_test_token_data();
8510        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8511        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8512        app.handle_key_event(key);
8513        assert!(app.should_exit);
8514    }
8515
8516    #[test]
8517    fn test_app_handle_key_quit_stops_active_export() {
8518        let data = create_test_token_data();
8519        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8520        let path = start_export_in_temp(&mut app.state);
8521        assert!(app.state.export_active);
8522
8523        app.handle_key_event(make_key_event(KeyCode::Char('q')));
8524        assert!(app.should_exit);
8525        assert!(!app.state.export_active);
8526        let _ = std::fs::remove_file(path);
8527    }
8528
8529    #[test]
8530    fn test_app_handle_key_ctrl_c_stops_active_export() {
8531        let data = create_test_token_data();
8532        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8533        let path = start_export_in_temp(&mut app.state);
8534        assert!(app.state.export_active);
8535
8536        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8537        app.handle_key_event(key);
8538        assert!(app.should_exit);
8539        assert!(!app.state.export_active);
8540        let _ = std::fs::remove_file(path);
8541    }
8542
8543    #[test]
8544    fn test_app_handle_key_updates_last_input_time() {
8545        let data = create_test_token_data();
8546        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8547        let before = Instant::now();
8548        app.handle_key_event(make_key_event(KeyCode::Char('p')));
8549        assert!(app.state.last_input_at >= before);
8550    }
8551
8552    #[test]
8553    fn test_app_handle_key_widget_toggle_mode() {
8554        let data = create_test_token_data();
8555        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8556        assert!(app.state.widgets.price_chart);
8557
8558        // Enter widget toggle mode
8559        app.handle_key_event(make_key_event(KeyCode::Char('w')));
8560        assert!(app.state.widget_toggle_mode);
8561
8562        // Toggle widget 1 (price chart)
8563        app.handle_key_event(make_key_event(KeyCode::Char('1')));
8564        assert!(!app.state.widget_toggle_mode);
8565        assert!(!app.state.widgets.price_chart);
8566    }
8567
8568    #[test]
8569    fn test_app_handle_key_widget_toggle_mode_cancel() {
8570        let data = create_test_token_data();
8571        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8572
8573        // Enter widget toggle mode
8574        app.handle_key_event(make_key_event(KeyCode::Char('w')));
8575        assert!(app.state.widget_toggle_mode);
8576
8577        // Any non-digit key cancels widget toggle mode
8578        app.handle_key_event(make_key_event(KeyCode::Char('x')));
8579        assert!(!app.state.widget_toggle_mode);
8580    }
8581
8582    #[test]
8583    fn test_app_handle_key_all_keybindings() {
8584        let data = create_test_token_data();
8585        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8586
8587        // r = force refresh
8588        app.handle_key_event(make_key_event(KeyCode::Char('r')));
8589        assert!(!app.should_exit);
8590
8591        // Shift+P = toggle auto-pause
8592        let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
8593        app.handle_key_event(key);
8594        assert!(app.state.auto_pause_on_input);
8595        app.handle_key_event(key);
8596        assert!(!app.state.auto_pause_on_input);
8597
8598        // p = toggle pause
8599        app.handle_key_event(make_key_event(KeyCode::Char('p')));
8600        assert!(app.state.paused);
8601
8602        // space = toggle pause
8603        app.handle_key_event(make_key_event(KeyCode::Char(' ')));
8604        assert!(!app.state.paused);
8605
8606        // e = toggle export
8607        app.handle_key_event(make_key_event(KeyCode::Char('e')));
8608        assert!(app.state.export_active);
8609        // Stop export to avoid file handles
8610        app.state.stop_export();
8611
8612        // + = slower refresh
8613        let before_rate = app.state.refresh_rate;
8614        app.handle_key_event(make_key_event(KeyCode::Char('+')));
8615        assert!(app.state.refresh_rate >= before_rate);
8616
8617        // - = faster refresh
8618        let before_rate = app.state.refresh_rate;
8619        app.handle_key_event(make_key_event(KeyCode::Char('-')));
8620        assert!(app.state.refresh_rate <= before_rate);
8621
8622        // 1-6 = time periods
8623        app.handle_key_event(make_key_event(KeyCode::Char('1')));
8624        assert_eq!(app.state.time_period, TimePeriod::Min1);
8625        app.handle_key_event(make_key_event(KeyCode::Char('2')));
8626        assert_eq!(app.state.time_period, TimePeriod::Min5);
8627        app.handle_key_event(make_key_event(KeyCode::Char('3')));
8628        assert_eq!(app.state.time_period, TimePeriod::Min15);
8629        app.handle_key_event(make_key_event(KeyCode::Char('4')));
8630        assert_eq!(app.state.time_period, TimePeriod::Hour1);
8631        app.handle_key_event(make_key_event(KeyCode::Char('5')));
8632        assert_eq!(app.state.time_period, TimePeriod::Hour4);
8633        app.handle_key_event(make_key_event(KeyCode::Char('6')));
8634        assert_eq!(app.state.time_period, TimePeriod::Day1);
8635
8636        // t = cycle time period
8637        app.handle_key_event(make_key_event(KeyCode::Char('t')));
8638        assert_eq!(app.state.time_period, TimePeriod::Min1); // wraps from Day1
8639
8640        // c = toggle chart mode
8641        app.handle_key_event(make_key_event(KeyCode::Char('c')));
8642        assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
8643
8644        // s = toggle scale
8645        app.handle_key_event(make_key_event(KeyCode::Char('s')));
8646        assert_eq!(app.state.scale_mode, ScaleMode::Log);
8647
8648        // / = cycle color scheme
8649        app.handle_key_event(make_key_event(KeyCode::Char('/')));
8650        assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
8651
8652        // j = scroll log down
8653        app.handle_key_event(make_key_event(KeyCode::Char('j')));
8654
8655        // k = scroll log up
8656        app.handle_key_event(make_key_event(KeyCode::Char('k')));
8657
8658        // l = next layout
8659        app.handle_key_event(make_key_event(KeyCode::Char('l')));
8660        assert!(!app.state.auto_layout);
8661
8662        // h = prev layout
8663        app.handle_key_event(make_key_event(KeyCode::Char('h')));
8664
8665        // a = re-enable auto layout
8666        app.handle_key_event(make_key_event(KeyCode::Char('a')));
8667        assert!(app.state.auto_layout);
8668
8669        // w = widget toggle mode
8670        app.handle_key_event(make_key_event(KeyCode::Char('w')));
8671        assert!(app.state.widget_toggle_mode);
8672        // Cancel it
8673        app.handle_key_event(make_key_event(KeyCode::Char('z')));
8674
8675        // Unknown key is a no-op
8676        app.handle_key_event(make_key_event(KeyCode::F(12)));
8677        assert!(!app.should_exit);
8678    }
8679
8680    // ========================================================================
8681    // Integration tests: MonitorApp::fetch_data
8682    // ========================================================================
8683
8684    #[tokio::test]
8685    async fn test_app_fetch_data_success() {
8686        let data = create_test_token_data();
8687        let initial_price = data.price_usd;
8688        let mut updated = data.clone();
8689        updated.price_usd = 2.5;
8690        let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
8691
8692        assert!((app.state.current_price - initial_price).abs() < 0.001);
8693        app.fetch_data().await;
8694        assert!((app.state.current_price - 2.5).abs() < 0.001);
8695        assert!(app.state.error_message.is_none());
8696    }
8697
8698    #[tokio::test]
8699    async fn test_app_fetch_data_api_error() {
8700        let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
8701
8702        app.fetch_data().await;
8703        assert!(app.state.error_message.is_some());
8704        assert!(
8705            app.state
8706                .error_message
8707                .as_ref()
8708                .unwrap()
8709                .contains("API Error")
8710        );
8711    }
8712
8713    #[tokio::test]
8714    async fn test_app_fetch_data_holder_count_on_12th_tick() {
8715        let data = create_test_token_data();
8716        let mock_chain = MockChainClient::new(42_000);
8717        let mut app = create_test_app(
8718            Box::new(MockDexDataSource::new(data)),
8719            Some(Box::new(mock_chain)),
8720        );
8721
8722        // First 11 fetches should not update holder count
8723        for _ in 0..11 {
8724            app.fetch_data().await;
8725        }
8726        assert!(app.state.holder_count.is_none());
8727
8728        // 12th fetch triggers holder count lookup
8729        app.fetch_data().await;
8730        assert_eq!(app.state.holder_count, Some(42_000));
8731    }
8732
8733    #[tokio::test]
8734    async fn test_app_fetch_data_holder_count_zero_not_stored() {
8735        let data = create_test_token_data();
8736        let mock_chain = MockChainClient::new(0); // returns zero
8737        let mut app = create_test_app(
8738            Box::new(MockDexDataSource::new(data)),
8739            Some(Box::new(mock_chain)),
8740        );
8741
8742        // Skip to 12th tick
8743        app.state.holder_fetch_counter = 11;
8744        app.fetch_data().await;
8745        // Zero holder count should NOT be stored
8746        assert!(app.state.holder_count.is_none());
8747    }
8748
8749    #[tokio::test]
8750    async fn test_app_fetch_data_no_chain_client_skips_holders() {
8751        let data = create_test_token_data();
8752        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8753
8754        // Skip to 12th tick
8755        app.state.holder_fetch_counter = 11;
8756        app.fetch_data().await;
8757        // Without a chain client, holder count stays None
8758        assert!(app.state.holder_count.is_none());
8759    }
8760
8761    #[tokio::test]
8762    async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
8763        let data = create_test_token_data();
8764        let mock_chain = MockChainClient::new(42_000);
8765        let mut app = create_test_app(
8766            Box::new(MockDexDataSource::new(data)),
8767            Some(Box::new(mock_chain)),
8768        );
8769
8770        // Fetch holder count on 12th tick
8771        app.state.holder_fetch_counter = 11;
8772        app.fetch_data().await;
8773        assert_eq!(app.state.holder_count, Some(42_000));
8774
8775        // Replace chain client with one returning 0
8776        app.chain_client = Some(Box::new(MockChainClient::new(0)));
8777        // 24th tick
8778        app.state.holder_fetch_counter = 23;
8779        app.fetch_data().await;
8780        // Previous value should be preserved (zero is ignored)
8781        assert_eq!(app.state.holder_count, Some(42_000));
8782    }
8783
8784    // ========================================================================
8785    // Integration tests: MonitorApp::cleanup
8786    // ========================================================================
8787
8788    #[test]
8789    fn test_app_cleanup_does_not_panic_test_backend() {
8790        let data = create_test_token_data();
8791        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8792        // cleanup() with owns_terminal=false should not attempt terminal restore
8793        let result = app.cleanup();
8794        assert!(result.is_ok());
8795    }
8796
8797    // ========================================================================
8798    // Integration tests: MonitorApp draw renders without panic
8799    // ========================================================================
8800
8801    #[test]
8802    fn test_app_draw_renders_ui() {
8803        let data = create_test_token_data();
8804        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8805        // Verify we can render UI through the MonitorApp terminal
8806        app.terminal
8807            .draw(|f| ui(f, &mut app.state))
8808            .expect("should render without panic");
8809    }
8810
8811    // ========================================================================
8812    // Integration tests: select_token_impl
8813    // ========================================================================
8814
8815    fn make_search_results() -> Vec<TokenSearchResult> {
8816        vec![
8817            TokenSearchResult {
8818                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
8819                symbol: "USDC".to_string(),
8820                name: "USD Coin".to_string(),
8821                chain: "ethereum".to_string(),
8822                price_usd: Some(1.0),
8823                volume_24h: 5_000_000_000.0,
8824                liquidity_usd: 2_000_000_000.0,
8825                market_cap: Some(32_000_000_000.0),
8826            },
8827            TokenSearchResult {
8828                address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
8829                symbol: "USDT".to_string(),
8830                name: "Tether USD".to_string(),
8831                chain: "ethereum".to_string(),
8832                price_usd: Some(1.0),
8833                volume_24h: 6_000_000_000.0,
8834                liquidity_usd: 3_000_000_000.0,
8835                market_cap: Some(83_000_000_000.0),
8836            },
8837            TokenSearchResult {
8838                address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
8839                symbol: "DAI".to_string(),
8840                name: "Dai Stablecoin".to_string(),
8841                chain: "ethereum".to_string(),
8842                price_usd: Some(1.0),
8843                volume_24h: 200_000_000.0,
8844                liquidity_usd: 500_000_000.0,
8845                market_cap: Some(5_000_000_000.0),
8846            },
8847        ]
8848    }
8849
8850    #[test]
8851    fn test_select_token_impl_valid_first() {
8852        let results = make_search_results();
8853        let mut reader = io::Cursor::new(b"1\n");
8854        let mut writer = Vec::new();
8855        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8856        assert_eq!(selected.symbol, "USDC");
8857        assert_eq!(selected.address, results[0].address);
8858        let output = String::from_utf8(writer).unwrap();
8859        assert!(output.contains("Found 3 tokens"));
8860        assert!(output.contains("Selected: USDC"));
8861    }
8862
8863    #[test]
8864    fn test_select_token_impl_valid_last() {
8865        let results = make_search_results();
8866        let mut reader = io::Cursor::new(b"3\n");
8867        let mut writer = Vec::new();
8868        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8869        assert_eq!(selected.symbol, "DAI");
8870    }
8871
8872    #[test]
8873    fn test_select_token_impl_valid_middle() {
8874        let results = make_search_results();
8875        let mut reader = io::Cursor::new(b"2\n");
8876        let mut writer = Vec::new();
8877        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8878        assert_eq!(selected.symbol, "USDT");
8879    }
8880
8881    #[test]
8882    fn test_select_token_impl_out_of_bounds_zero() {
8883        let results = make_search_results();
8884        let mut reader = io::Cursor::new(b"0\n");
8885        let mut writer = Vec::new();
8886        let result = select_token_impl(&results, &mut reader, &mut writer);
8887        assert!(result.is_err());
8888        let err = result.unwrap_err().to_string();
8889        assert!(err.contains("Selection must be between 1 and 3"));
8890    }
8891
8892    #[test]
8893    fn test_select_token_impl_out_of_bounds_high() {
8894        let results = make_search_results();
8895        let mut reader = io::Cursor::new(b"99\n");
8896        let mut writer = Vec::new();
8897        let result = select_token_impl(&results, &mut reader, &mut writer);
8898        assert!(result.is_err());
8899    }
8900
8901    #[test]
8902    fn test_select_token_impl_non_numeric_input() {
8903        let results = make_search_results();
8904        let mut reader = io::Cursor::new(b"abc\n");
8905        let mut writer = Vec::new();
8906        let result = select_token_impl(&results, &mut reader, &mut writer);
8907        assert!(result.is_err());
8908        let err = result.unwrap_err().to_string();
8909        assert!(err.contains("Invalid selection"));
8910    }
8911
8912    #[test]
8913    fn test_select_token_impl_empty_input() {
8914        let results = make_search_results();
8915        let mut reader = io::Cursor::new(b"\n");
8916        let mut writer = Vec::new();
8917        let result = select_token_impl(&results, &mut reader, &mut writer);
8918        assert!(result.is_err());
8919    }
8920
8921    #[test]
8922    fn test_select_token_impl_long_name_truncation() {
8923        let results = vec![TokenSearchResult {
8924            address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
8925            symbol: "LONG".to_string(),
8926            name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
8927            chain: "ethereum".to_string(),
8928            price_usd: None,
8929            volume_24h: 100.0,
8930            liquidity_usd: 50.0,
8931            market_cap: None,
8932        }];
8933        let mut reader = io::Cursor::new(b"1\n");
8934        let mut writer = Vec::new();
8935        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8936        assert_eq!(selected.symbol, "LONG");
8937        let output = String::from_utf8(writer).unwrap();
8938        // Should have truncated name
8939        assert!(output.contains("A Very Long Token..."));
8940        // Should show N/A for price
8941        assert!(output.contains("N/A"));
8942    }
8943
8944    #[test]
8945    fn test_select_token_impl_output_format() {
8946        let results = make_search_results();
8947        let mut reader = io::Cursor::new(b"1\n");
8948        let mut writer = Vec::new();
8949        let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8950        let output = String::from_utf8(writer).unwrap();
8951
8952        // Verify table header
8953        assert!(output.contains("#"));
8954        assert!(output.contains("Symbol"));
8955        assert!(output.contains("Name"));
8956        assert!(output.contains("Address"));
8957        assert!(output.contains("Price"));
8958        assert!(output.contains("Liquidity"));
8959        // Verify separator line
8960        assert!(output.contains("─"));
8961        // Verify prompt
8962        assert!(output.contains("Select token (1-3):"));
8963    }
8964
8965    // ========================================================================
8966    // Integration tests: format_monitor_number
8967    // ========================================================================
8968
8969    #[test]
8970    fn test_format_monitor_number_billions() {
8971        assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
8972        assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
8973    }
8974
8975    #[test]
8976    fn test_format_monitor_number_millions() {
8977        assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
8978        assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
8979    }
8980
8981    #[test]
8982    fn test_format_monitor_number_thousands() {
8983        assert_eq!(format_monitor_number(5_000.0), "$5.00K");
8984        assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
8985    }
8986
8987    #[test]
8988    fn test_format_monitor_number_small() {
8989        assert_eq!(format_monitor_number(42.0), "$42.00");
8990        assert_eq!(format_monitor_number(0.5), "$0.50");
8991        assert_eq!(format_monitor_number(0.0), "$0.00");
8992    }
8993
8994    // ========================================================================
8995    // Integration tests: abbreviate_address edge cases
8996    // ========================================================================
8997
8998    #[test]
8999    fn test_abbreviate_address_exactly_16_chars() {
9000        let addr = "0123456789ABCDEF"; // exactly 16 chars
9001        assert_eq!(abbreviate_address(addr), addr);
9002    }
9003
9004    #[test]
9005    fn test_abbreviate_address_17_chars() {
9006        let addr = "0123456789ABCDEFG"; // 17 chars -> abbreviated
9007        assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
9008    }
9009
9010    // ========================================================================
9011    // Integration tests: MonitorApp with state + fetch combined scenario
9012    // ========================================================================
9013
9014    #[tokio::test]
9015    async fn test_app_full_scenario_fetch_render_quit() {
9016        let data = create_test_token_data();
9017        let mut updated = data.clone();
9018        updated.price_usd = 3.0;
9019        let mock_chain = MockChainClient::new(10_000);
9020        let state = MonitorState::new(&data, "ethereum");
9021        let mut app = create_test_app_with_state(
9022            state,
9023            Box::new(MockDexDataSource::new(updated)),
9024            Some(Box::new(mock_chain)),
9025        );
9026
9027        // 1. Fetch new data
9028        app.fetch_data().await;
9029        assert!((app.state.current_price - 3.0).abs() < 0.001);
9030
9031        // 2. Render UI
9032        app.terminal
9033            .draw(|f| ui(f, &mut app.state))
9034            .expect("render");
9035
9036        // 3. Start export
9037        app.handle_key_event(make_key_event(KeyCode::Char('e')));
9038        assert!(app.state.export_active);
9039
9040        // 4. Quit (should stop export)
9041        app.handle_key_event(make_key_event(KeyCode::Char('q')));
9042        assert!(app.should_exit);
9043        assert!(!app.state.export_active);
9044    }
9045
9046    #[tokio::test]
9047    async fn test_app_fetch_data_error_then_recovery() {
9048        let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
9049
9050        // First fetch fails
9051        app.fetch_data().await;
9052        assert!(app.state.error_message.is_some());
9053
9054        // Replace with working mock
9055        let mut recovered = create_test_token_data();
9056        recovered.price_usd = 5.0;
9057        app.dex_client = Box::new(MockDexDataSource::new(recovered));
9058
9059        // Second fetch succeeds
9060        app.fetch_data().await;
9061        assert!((app.state.current_price - 5.0).abs() < 0.001);
9062        // Error message is cleared by state.update()
9063    }
9064
9065    // ========================================================================
9066    // Integration tests: MonitorArgs parsing and run_direct config merging
9067    // ========================================================================
9068
9069    #[test]
9070    fn test_monitor_args_defaults() {
9071        use super::super::Cli;
9072        use clap::Parser;
9073        // Simulate: scope monitor USDC
9074        let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
9075        if let super::super::Commands::Monitor(args) = cli.command {
9076            assert_eq!(args.token, "USDC");
9077            assert_eq!(args.chain, "ethereum");
9078            assert!(args.layout.is_none());
9079            assert!(args.refresh.is_none());
9080            assert!(args.scale.is_none());
9081            assert!(args.color_scheme.is_none());
9082            assert!(args.export.is_none());
9083        } else {
9084            panic!("Expected Monitor command");
9085        }
9086    }
9087
9088    #[test]
9089    fn test_monitor_args_all_flags() {
9090        use super::super::Cli;
9091        use clap::Parser;
9092        let cli = Cli::try_parse_from([
9093            "scope",
9094            "monitor",
9095            "PEPE",
9096            "--chain",
9097            "solana",
9098            "--layout",
9099            "feed",
9100            "--refresh",
9101            "2",
9102            "--scale",
9103            "log",
9104            "--color-scheme",
9105            "monochrome",
9106            "--export",
9107            "/tmp/data.csv",
9108        ])
9109        .unwrap();
9110        if let super::super::Commands::Monitor(args) = cli.command {
9111            assert_eq!(args.token, "PEPE");
9112            assert_eq!(args.chain, "solana");
9113            assert_eq!(args.layout, Some(LayoutPreset::Feed));
9114            assert_eq!(args.refresh, Some(2));
9115            assert_eq!(args.scale, Some(ScaleMode::Log));
9116            assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
9117            assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
9118        } else {
9119            panic!("Expected Monitor command");
9120        }
9121    }
9122
9123    #[test]
9124    fn test_run_direct_config_override_layout() {
9125        // Verify that run_direct properly applies CLI overrides to config
9126        let config = Config::default();
9127        assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
9128
9129        let args = MonitorArgs {
9130            token: "USDC".to_string(),
9131            chain: "ethereum".to_string(),
9132            layout: Some(LayoutPreset::ChartFocus),
9133            refresh: None,
9134            scale: None,
9135            color_scheme: None,
9136            export: None,
9137            venue: None,
9138            pair: None,
9139        };
9140
9141        // Build the effective config the same way run_direct does
9142        let mut monitor_config = config.monitor.clone();
9143        if let Some(layout) = args.layout {
9144            monitor_config.layout = layout;
9145        }
9146        assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
9147    }
9148
9149    #[test]
9150    fn test_run_direct_config_override_all_fields() {
9151        let config = Config::default();
9152        let args = MonitorArgs {
9153            token: "PEPE".to_string(),
9154            chain: "solana".to_string(),
9155            layout: Some(LayoutPreset::Compact),
9156            refresh: Some(2),
9157            scale: Some(ScaleMode::Log),
9158            color_scheme: Some(ColorScheme::BlueOrange),
9159            export: Some(PathBuf::from("/tmp/test.csv")),
9160            venue: None,
9161            pair: None,
9162        };
9163
9164        let mut mc = config.monitor.clone();
9165        if let Some(layout) = args.layout {
9166            mc.layout = layout;
9167        }
9168        if let Some(refresh) = args.refresh {
9169            mc.refresh_seconds = refresh;
9170        }
9171        if let Some(scale) = args.scale {
9172            mc.scale = scale;
9173        }
9174        if let Some(color_scheme) = args.color_scheme {
9175            mc.color_scheme = color_scheme;
9176        }
9177        if let Some(ref path) = args.export {
9178            mc.export.path = Some(path.to_string_lossy().into_owned());
9179        }
9180
9181        assert_eq!(mc.layout, LayoutPreset::Compact);
9182        assert_eq!(mc.refresh_seconds, 2);
9183        assert_eq!(mc.scale, ScaleMode::Log);
9184        assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
9185        assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
9186    }
9187
9188    #[test]
9189    fn test_run_direct_config_no_overrides_preserves_defaults() {
9190        let config = Config::default();
9191        let args = MonitorArgs {
9192            token: "USDC".to_string(),
9193            chain: "ethereum".to_string(),
9194            layout: None,
9195            refresh: None,
9196            scale: None,
9197            color_scheme: None,
9198            export: None,
9199            venue: None,
9200            pair: None,
9201        };
9202
9203        let mut mc = config.monitor.clone();
9204        if let Some(layout) = args.layout {
9205            mc.layout = layout;
9206        }
9207        if let Some(refresh) = args.refresh {
9208            mc.refresh_seconds = refresh;
9209        }
9210        if let Some(scale) = args.scale {
9211            mc.scale = scale;
9212        }
9213        if let Some(color_scheme) = args.color_scheme {
9214            mc.color_scheme = color_scheme;
9215        }
9216
9217        // All should remain at defaults
9218        assert_eq!(mc.layout, LayoutPreset::Dashboard);
9219        assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
9220        assert_eq!(mc.scale, ScaleMode::Linear);
9221        assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
9222        assert!(mc.export.path.is_none());
9223    }
9224
9225    // =================================================================
9226    // OHLC / exchange_interval tests
9227    // =================================================================
9228
9229    #[test]
9230    fn test_exchange_interval_mapping() {
9231        assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
9232        assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
9233        assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
9234        assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
9235        assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
9236        assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
9237    }
9238
9239    #[test]
9240    fn test_monitor_state_exchange_ohlc_default_empty() {
9241        let token_data = create_test_token_data();
9242        let state = MonitorState::new(&token_data, "ethereum");
9243        assert!(state.exchange_ohlc.is_empty());
9244        assert!(state.venue_pair.is_none());
9245    }
9246
9247    #[test]
9248    fn test_get_ohlc_candles_prefers_exchange_data() {
9249        let token_data = create_test_token_data();
9250        let mut state = MonitorState::new(&token_data, "ethereum");
9251
9252        // Add some synthetic candles from price history
9253        let now = std::time::SystemTime::now()
9254            .duration_since(std::time::UNIX_EPOCH)
9255            .unwrap()
9256            .as_secs_f64();
9257        state.price_history.push_back(DataPoint {
9258            timestamp: now,
9259            value: 1.0,
9260            is_real: true,
9261        });
9262        state.price_history.push_back(DataPoint {
9263            timestamp: now + 5.0,
9264            value: 1.01,
9265            is_real: true,
9266        });
9267
9268        // Without exchange OHLC, should get synthetic candles
9269        let candles_before = state.get_ohlc_candles();
9270
9271        // Now add exchange OHLC data
9272        state.exchange_ohlc = vec![
9273            OhlcCandle {
9274                timestamp: 1700000000.0,
9275                open: 50000.0,
9276                high: 50500.0,
9277                low: 49800.0,
9278                close: 50200.0,
9279                is_bullish: true,
9280            },
9281            OhlcCandle {
9282                timestamp: 1700003600.0,
9283                open: 50200.0,
9284                high: 50800.0,
9285                low: 50100.0,
9286                close: 50700.0,
9287                is_bullish: true,
9288            },
9289        ];
9290
9291        let candles_after = state.get_ohlc_candles();
9292        assert_eq!(candles_after.len(), 2);
9293        assert_eq!(candles_after[0].open, 50000.0);
9294        assert_eq!(candles_after[1].close, 50700.0);
9295
9296        // Verify exchange data is preferred over synthetic
9297        if !candles_before.is_empty() {
9298            assert_ne!(candles_after[0].open, candles_before[0].open);
9299        }
9300    }
9301
9302    #[test]
9303    fn test_monitor_args_with_venue() {
9304        let args = MonitorArgs {
9305            token: "BTC".to_string(),
9306            chain: "ethereum".to_string(),
9307            refresh: None,
9308            layout: None,
9309            scale: None,
9310            color_scheme: None,
9311            export: None,
9312            venue: Some("binance".to_string()),
9313            pair: None,
9314        };
9315        assert_eq!(args.venue, Some("binance".to_string()));
9316    }
9317
9318    #[test]
9319    fn test_ohlc_candle_is_bullish_calculation() {
9320        let bullish = OhlcCandle {
9321            timestamp: 1700000000.0,
9322            open: 100.0,
9323            high: 110.0,
9324            low: 95.0,
9325            close: 105.0,
9326            is_bullish: true,
9327        };
9328        assert!(bullish.is_bullish);
9329
9330        let bearish = OhlcCandle {
9331            timestamp: 1700000000.0,
9332            open: 100.0,
9333            high: 105.0,
9334            low: 90.0,
9335            close: 95.0,
9336            is_bullish: false,
9337        };
9338        assert!(!bearish.is_bullish);
9339    }
9340
9341    #[test]
9342    fn test_build_exchange_token_data_from_ticker() {
9343        let ticker = crate::market::Ticker {
9344            pair: "PUSD/USDT".to_string(),
9345            last_price: Some(1.001),
9346            high_24h: Some(1.005),
9347            low_24h: Some(0.998),
9348            volume_24h: Some(500_000.0),
9349            quote_volume_24h: Some(500_500.0),
9350            best_bid: Some(1.0005),
9351            best_ask: Some(1.0015),
9352        };
9353
9354        let data = build_exchange_token_data("PUSD", "PUSD_USDT", &ticker);
9355
9356        assert_eq!(data.symbol, "PUSD");
9357        assert_eq!(data.name, "PUSD");
9358        assert_eq!(data.price_usd, 1.001);
9359        assert_eq!(data.volume_24h, 500_000.0);
9360        assert!(data.address.contains("exchange:"));
9361        assert!(data.pairs.is_empty());
9362        assert!(data.price_history.is_empty());
9363        assert!(data.dexscreener_url.is_none());
9364    }
9365
9366    #[test]
9367    fn test_build_exchange_token_data_missing_price() {
9368        let ticker = crate::market::Ticker {
9369            pair: "FOO/USDT".to_string(),
9370            last_price: None,
9371            high_24h: None,
9372            low_24h: None,
9373            volume_24h: None,
9374            quote_volume_24h: None,
9375            best_bid: None,
9376            best_ask: None,
9377        };
9378
9379        let data = build_exchange_token_data("FOO", "FOO_USDT", &ticker);
9380        assert_eq!(data.price_usd, 0.0);
9381        assert_eq!(data.volume_24h, 0.0);
9382    }
9383
9384    #[test]
9385    fn test_monitor_args_with_pair() {
9386        let args = MonitorArgs {
9387            token: "PUSD".to_string(),
9388            chain: "ethereum".to_string(),
9389            refresh: None,
9390            layout: None,
9391            scale: None,
9392            color_scheme: None,
9393            export: None,
9394            venue: Some("biconomy".to_string()),
9395            pair: Some("PUSD_USDT".to_string()),
9396        };
9397        assert_eq!(args.pair, Some("PUSD_USDT".to_string()));
9398        assert_eq!(args.venue, Some("biconomy".to_string()));
9399    }
9400
9401    #[test]
9402    fn test_monitor_args_pair_none_by_default() {
9403        let args = MonitorArgs {
9404            token: "BTC".to_string(),
9405            chain: "ethereum".to_string(),
9406            refresh: None,
9407            layout: None,
9408            scale: None,
9409            color_scheme: None,
9410            export: None,
9411            venue: None,
9412            pair: None,
9413        };
9414        assert!(args.pair.is_none());
9415    }
9416
9417    #[test]
9418    fn test_build_exchange_token_data_extracts_base_symbol() {
9419        let ticker = crate::market::Ticker {
9420            pair: "DOGE/USDT".to_string(),
9421            last_price: Some(0.123),
9422            high_24h: Some(0.13),
9423            low_24h: Some(0.12),
9424            volume_24h: Some(1_000_000.0),
9425            quote_volume_24h: Some(123_000.0),
9426            best_bid: Some(0.1225),
9427            best_ask: Some(0.1235),
9428        };
9429
9430        let data = build_exchange_token_data("DOGE", "DOGE_USDT", &ticker);
9431        assert_eq!(data.symbol, "DOGE");
9432        assert_eq!(data.name, "DOGE");
9433        assert_eq!(data.price_usd, 0.123);
9434        assert_eq!(data.volume_24h, 1_000_000.0);
9435        // Verify all DEX-specific fields are zeroed/empty
9436        assert_eq!(data.price_change_24h, 0.0);
9437        assert_eq!(data.price_change_6h, 0.0);
9438        assert_eq!(data.price_change_1h, 0.0);
9439        assert_eq!(data.price_change_5m, 0.0);
9440        assert_eq!(data.volume_6h, 0.0);
9441        assert_eq!(data.volume_1h, 0.0);
9442        assert_eq!(data.liquidity_usd, 0.0);
9443        assert!(data.market_cap.is_none());
9444        assert!(data.fdv.is_none());
9445        assert!(data.earliest_pair_created_at.is_none());
9446        assert!(data.image_url.is_none());
9447        assert!(data.websites.is_empty());
9448        assert!(data.socials.is_empty());
9449        assert_eq!(data.total_buys_24h, 0);
9450        assert_eq!(data.total_sells_24h, 0);
9451    }
9452
9453    #[test]
9454    fn test_build_exchange_token_data_address_format() {
9455        let ticker = crate::market::Ticker {
9456            pair: "X/Y".to_string(),
9457            last_price: Some(1.0),
9458            high_24h: None,
9459            low_24h: None,
9460            volume_24h: None,
9461            quote_volume_24h: None,
9462            best_bid: None,
9463            best_ask: None,
9464        };
9465
9466        let data = build_exchange_token_data("X", "X_Y", &ticker);
9467        assert_eq!(data.address, "exchange:X_Y");
9468    }
9469
9470    #[test]
9471    fn test_monitor_args_pair_requires_venue_conceptually() {
9472        // When --pair is set, --venue should also be set.
9473        // This is a structural test; the runtime validation is in run().
9474        let args = MonitorArgs {
9475            token: "PUSD".to_string(),
9476            chain: "ethereum".to_string(),
9477            refresh: None,
9478            layout: None,
9479            scale: None,
9480            color_scheme: None,
9481            export: None,
9482            venue: Some("biconomy".to_string()),
9483            pair: Some("PUSD_USDT".to_string()),
9484        };
9485        assert!(args.venue.is_some());
9486        assert!(args.pair.is_some());
9487    }
9488
9489    #[test]
9490    fn test_run_direct_config_pair_passthrough() {
9491        // Verify that run_direct properly propagates the pair field
9492        let config = Config::default();
9493        let args = MonitorArgs {
9494            token: "PUSD".to_string(),
9495            chain: "ethereum".to_string(),
9496            layout: None,
9497            refresh: None,
9498            scale: None,
9499            color_scheme: None,
9500            export: None,
9501            venue: Some("biconomy".to_string()),
9502            pair: Some("PUSD_USDT".to_string()),
9503        };
9504
9505        // Simulate the config override path from run_direct
9506        let mut mc = config.monitor.clone();
9507        if let Some(ref venue) = args.venue {
9508            mc.venue = Some(venue.clone());
9509        }
9510        assert_eq!(mc.venue, Some("biconomy".to_string()));
9511        // pair is passed directly to run(), not stored in MonitorConfig
9512        assert_eq!(args.pair, Some("PUSD_USDT".to_string()));
9513    }
9514
9515    // =========================================================================
9516    // resolve_token_address chain filter tests
9517    // =========================================================================
9518
9519    #[test]
9520    fn test_chain_filter_logic_ethereum_default() {
9521        // When chain is "ethereum" (default), chain_filter should be None
9522        // so we search ALL chains and exact matches on any chain sort first.
9523        let chain = "ethereum";
9524        let chain_filter: Option<&str> = if chain != "ethereum" {
9525            Some(chain)
9526        } else {
9527            None
9528        };
9529        assert!(chain_filter.is_none());
9530    }
9531
9532    #[test]
9533    fn test_chain_filter_logic_explicit_chain() {
9534        // When chain is explicitly set (not "ethereum"), filter to that chain.
9535        let chain = "solana";
9536        let chain_filter: Option<&str> = if chain != "ethereum" {
9537            Some(chain)
9538        } else {
9539            None
9540        };
9541        assert_eq!(chain_filter, Some("solana"));
9542    }
9543
9544    #[test]
9545    fn test_chain_filter_logic_bsc() {
9546        let chain = "bsc";
9547        let chain_filter: Option<&str> = if chain != "ethereum" {
9548            Some(chain)
9549        } else {
9550            None
9551        };
9552        assert_eq!(chain_filter, Some("bsc"));
9553    }
9554
9555    #[tokio::test]
9556    async fn test_resolve_token_address_with_address_input() {
9557        use crate::chains::dex::DexClient;
9558        let config = Config::default();
9559        let dex = DexClient::new();
9560        // EVM address should be returned directly, no DexScreener query
9561        let result = resolve_token_address(
9562            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
9563            "ethereum",
9564            &config,
9565            &dex,
9566        )
9567        .await
9568        .unwrap();
9569        assert_eq!(result, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
9570    }
9571
9572    #[tokio::test]
9573    async fn test_resolve_token_address_solana_address() {
9574        use crate::chains::dex::DexClient;
9575        let config = Config::default();
9576        let dex = DexClient::new();
9577        // Solana address (base58, 32+ chars) should be returned directly
9578        let addr = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
9579        let result = resolve_token_address(addr, "solana", &config, &dex)
9580            .await
9581            .unwrap();
9582        assert_eq!(result, addr);
9583    }
9584
9585    #[test]
9586    fn test_try_cex_fallback_returns_correct_structure() {
9587        // Verify the fallback constructs results with the right shape
9588        // (can't easily test async CEX call without mocking, so test the structure)
9589        let result = crate::chains::TokenSearchResult {
9590            address: String::new(),
9591            symbol: "TEST".to_string(),
9592            name: "TEST".to_string(),
9593            chain: "ethereum".to_string(),
9594            price_usd: Some(1.0),
9595            volume_24h: 100.0,
9596            liquidity_usd: 0.0,
9597            market_cap: None,
9598        };
9599        assert_eq!(result.symbol, "TEST");
9600        assert!(result.address.is_empty());
9601        assert_eq!(result.liquidity_usd, 0.0);
9602    }
9603}