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