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