Skip to main content

irontide_session/
torrent.rs

1//! TorrentActor (single-owner event loop) and TorrentHandle (cloneable public API).
2//!
3//! The actor owns all per-torrent state (chunk tracking, piece selection, choking,
4//! peer management) and communicates with spawned PeerTasks via channels.
5//! The handle is a thin wrapper around an mpsc sender.
6
7use std::collections::{BTreeSet, HashMap, HashSet};
8use std::net::SocketAddr;
9
10use rustc_hash::FxHashMap;
11use std::sync::Arc;
12use std::sync::atomic::AtomicU32;
13use std::time::{Duration, Instant};
14
15use bytes::Bytes;
16use tokio::sync::{broadcast, mpsc, oneshot};
17use tracing::{debug, info, trace, warn};
18
19use crate::alert::{Alert, AlertKind, post_alert};
20use crate::disk::{DiskHandle, DiskJobFlags, DiskManagerHandle};
21use crate::piece_reservation::{
22    AtomicPieceStates, AvailabilitySnapshot, BlockMaps, StealCandidates,
23};
24
25use irontide_core::{
26    DEFAULT_CHUNK_SIZE, FilePriority, Id20, Lengths, Magnet, PeerId, TorrentMetaV1,
27    torrent_from_bytes,
28};
29use irontide_dht::DhtHandle;
30use irontide_storage::{Bitfield, ChunkTracker, MemoryStorage, TorrentStorage};
31
32use crate::choker::{Choker, PeerInfo as ChokerPeerInfo};
33use crate::end_game::EndGame;
34use crate::metadata::MetadataDownloader;
35use crate::peer_adder::{self, ConnectPeer};
36use crate::peer_state::{PeerSource, PeerState};
37use crate::tracker_manager::TrackerManager;
38use crate::types::{
39    PartialPieceInfo, PeerCommand, PeerEvent, PeerInfo, TorrentCommand, TorrentConfig,
40    TorrentState, TorrentStats,
41};
42
43/// Shared global rate limiter bucket.
44pub(crate) type SharedBucket = Arc<parking_lot::Mutex<crate::rate_limiter::TokenBucket>>;
45
46/// Tribool result for piece hash verification in hybrid torrents.
47///
48/// Mirrors libtorrent-rasterbar's `boost::tribool` approach for dual-hash
49/// verification. `NotApplicable` covers cases where verification cannot run
50/// (e.g. missing hash picker, disk error before any block is checked).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub(crate) enum HashResult {
53    /// All hashes matched.
54    Passed,
55    /// At least one hash did not match.
56    Failed,
57    /// Verification could not be performed (missing state / deferred).
58    NotApplicable,
59}
60
61/// Relocate torrent files from `src_base` to `dst_base`.
62///
63/// For each file, tries `rename` first (fast, same-filesystem), then falls
64/// back to copy + delete (cross-filesystem). Creates parent directories as
65/// needed. Returns error on the first failure.
66pub(crate) fn relocate_files(
67    src_base: &std::path::Path,
68    dst_base: &std::path::Path,
69    file_paths: &[std::path::PathBuf],
70) -> std::io::Result<()> {
71    for rel_path in file_paths {
72        let src = src_base.join(rel_path);
73        let dst = dst_base.join(rel_path);
74
75        if !src.exists() {
76            // File may not exist yet (e.g., not downloaded)
77            continue;
78        }
79
80        if let Some(parent) = dst.parent() {
81            std::fs::create_dir_all(parent)?;
82        }
83
84        // Try rename first (O(1) on same filesystem)
85        if std::fs::rename(&src, &dst).is_err() {
86            // Cross-filesystem: copy + delete
87            std::fs::copy(&src, &dst)?;
88            std::fs::remove_file(&src)?;
89        }
90    }
91
92    // Try to remove empty parent directories from source
93    // (best-effort, ignore errors)
94    for rel_path in file_paths {
95        let mut dir = src_base.join(rel_path);
96        dir.pop(); // get parent dir
97        while dir != *src_base {
98            if std::fs::remove_dir(&dir).is_err() {
99                break; // not empty or other error
100            }
101            dir.pop();
102        }
103    }
104
105    Ok(())
106}
107
108/// Current time as POSIX seconds (0 on clock error).
109pub(crate) fn now_unix() -> i64 {
110    std::time::SystemTime::now()
111        .duration_since(std::time::UNIX_EPOCH)
112        .map(|d| d.as_secs() as i64)
113        .unwrap_or(0)
114}
115
116/// M112: Cooldown period between holepunch attempts to the same address.
117pub(crate) const HOLEPUNCH_COOLDOWN: Duration = Duration::from_secs(120);
118
119/// M112: Maximum number of tracked holepunch cooldown entries to prevent unbounded growth.
120pub(crate) const HOLEPUNCH_MAX_TRACKED: usize = 256;
121
122/// Returns true if the disconnect reason suggests the peer is behind NAT
123/// and a holepunch attempt might succeed.
124pub(crate) fn should_attempt_holepunch(reason: &str) -> bool {
125    // Don't re-attempt holepunch for failures from a previous holepunch attempt
126    if reason.contains("holepunch") {
127        return false;
128    }
129    reason.contains("refused")
130        || reason.contains("timed out")
131        || reason.contains("Connection reset")
132        || reason.contains("connection reset")
133}
134
135/// Cloneable handle for interacting with a running torrent.
136#[derive(Clone)]
137pub struct TorrentHandle {
138    cmd_tx: mpsc::Sender<TorrentCommand>,
139}
140
141impl TorrentHandle {
142    /// Create a torrent session from parsed .torrent metadata.
143    ///
144    /// Spawns the actor event loop and returns a handle for sending commands.
145    #[allow(clippy::too_many_arguments)]
146    pub(crate) async fn from_torrent(
147        meta: TorrentMetaV1,
148        version: irontide_core::TorrentVersion,
149        meta_v2: Option<irontide_core::TorrentMetaV2>,
150        disk: DiskHandle,
151        disk_manager: DiskManagerHandle,
152        config: TorrentConfig,
153        dht: Option<DhtHandle>,
154        dht_v6: Option<DhtHandle>,
155        global_upload_bucket: Option<SharedBucket>,
156        global_download_bucket: Option<SharedBucket>,
157        slot_tuner: crate::slot_tuner::SlotTuner,
158        alert_tx: broadcast::Sender<Alert>,
159        alert_mask: Arc<AtomicU32>,
160        utp_socket: Option<irontide_utp::UtpSocket>,
161        utp_socket_v6: Option<irontide_utp::UtpSocket>,
162        ban_manager: crate::session::SharedBanManager,
163        ip_filter: crate::session::SharedIpFilter,
164        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
165        sam_session: Option<Arc<crate::i2p::SamSession>>,
166        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
167        factory: Arc<crate::transport::NetworkFactory>,
168        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
169    ) -> crate::Result<Self> {
170        let mut config = config;
171        // BEP 27: private torrents disable DHT, PEX, and LSD
172        if meta.info.private == Some(1) {
173            config.enable_dht = false;
174            config.enable_pex = false;
175            config.enable_lsd = false;
176        }
177
178        let info_hashes = match (&version, &meta_v2) {
179            (irontide_core::TorrentVersion::Hybrid, Some(v2_meta)) => {
180                if let Some(v2_hash) = v2_meta.info_hashes.v2 {
181                    irontide_core::InfoHashes::hybrid(meta.info_hash, v2_hash)
182                } else {
183                    irontide_core::InfoHashes::v1_only(meta.info_hash)
184                }
185            }
186            (irontide_core::TorrentVersion::V2Only, Some(v2_meta)) => v2_meta.info_hashes.clone(),
187            _ => irontide_core::InfoHashes::v1_only(meta.info_hash),
188        };
189
190        if meta.info.piece_length > config.max_piece_length {
191            return Err(crate::Error::InvalidSettings(format!(
192                "piece_length {} exceeds max_piece_length {}",
193                meta.info.piece_length, config.max_piece_length
194            )));
195        }
196
197        let num_pieces = meta.info.num_pieces() as u32;
198        let lengths = Lengths::new(
199            meta.info.total_length(),
200            meta.info.piece_length,
201            DEFAULT_CHUNK_SIZE,
202        );
203        let mut chunk_tracker = ChunkTracker::new(lengths.clone());
204
205        // Initialize HashPicker for v2/hybrid torrents and enable v2 block tracking
206        let hash_picker = if version.has_v2() {
207            if let Some(ref v2_meta) = meta_v2 {
208                chunk_tracker.enable_v2_tracking();
209
210                let block_size = 16384u64;
211                let blocks_per_piece = (meta.info.piece_length / block_size) as u32;
212
213                // Build FileHashInfo from v2 file tree
214                let v2_files = v2_meta.info.files();
215                let file_infos: Vec<irontide_core::FileHashInfo> = v2_files
216                    .iter()
217                    .filter_map(|f| {
218                        let root = f.attr.pieces_root?;
219                        let num_blocks = f.attr.length.div_ceil(block_size) as u32;
220                        let num_pieces = f.attr.length.div_ceil(meta.info.piece_length) as u32;
221                        Some(irontide_core::FileHashInfo {
222                            root,
223                            num_blocks,
224                            num_pieces,
225                        })
226                    })
227                    .collect();
228
229                if !file_infos.is_empty() {
230                    let mut picker = irontide_core::HashPicker::new(&file_infos, blocks_per_piece);
231
232                    // Pre-load piece-layer hashes from the .torrent file
233                    let _verified = picker.load_piece_layers(&v2_meta.piece_layers);
234
235                    Some(picker)
236                } else {
237                    None
238                }
239            } else {
240                None
241            }
242        } else {
243            None
244        };
245
246        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
247        let file_priorities = vec![FilePriority::Normal; file_lengths.len()];
248        let wanted_pieces =
249            crate::piece_selector::build_wanted_pieces(&file_priorities, &file_lengths, &lengths);
250
251        let (cmd_tx, cmd_rx) = mpsc::channel(256);
252        let (event_tx, event_rx) = mpsc::channel(2048);
253        let (write_error_tx, write_error_rx) = mpsc::channel(64);
254        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
255        let (hash_result_tx, hash_result_rx) = mpsc::channel(64); // M96
256        let our_peer_id = if config.anonymous_mode {
257            PeerId::generate_anonymous().0
258        } else {
259            PeerId::generate().0
260        };
261
262        // Bind listener for incoming connections
263        // Try dual-stack [::]:port first, fall back to IPv4-only
264        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
265            .bind_tcp(SocketAddr::from((
266                std::net::Ipv6Addr::UNSPECIFIED,
267                config.listen_port,
268            )))
269            .await
270        {
271            Ok(l) => Some(l),
272            Err(_) => factory
273                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
274                .await
275                .ok(),
276        };
277        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
278
279        let mut tracker_manager = TrackerManager::from_torrent_filtered(
280            &meta,
281            our_peer_id,
282            config.listen_port,
283            &config.url_security,
284            config.peer_dscp,
285            config.anonymous_mode,
286        );
287        tracker_manager.set_info_hashes(info_hashes.clone());
288
289        // BEP 7: include our I2P destination in tracker announces
290        if let Some(ref sam) = sam_session {
291            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
292        }
293
294        let enable_dht = config.enable_dht;
295
296        // Start DHT peer discovery if enabled and available
297        let dht_peers_rx = if enable_dht {
298            if let Some(ref dht) = dht {
299                match dht.get_peers(meta.info_hash).await {
300                    Ok(rx) => Some(rx),
301                    Err(e) => {
302                        warn!("failed to start DHT v4 get_peers: {e}");
303                        None
304                    }
305                }
306            } else {
307                None
308            }
309        } else {
310            None
311        };
312
313        let dht_v6_peers_rx = if enable_dht {
314            if let Some(ref dht6) = dht_v6 {
315                match dht6.get_peers(meta.info_hash).await {
316                    Ok(rx) => Some(rx),
317                    Err(e) => {
318                        debug!("failed to start DHT v6 get_peers: {e}");
319                        None
320                    }
321                }
322            } else {
323                None
324            }
325        } else {
326            None
327        };
328
329        // Dual-swarm: also search for v2 hash peers if hybrid
330        let v2_as_v1 = if info_hashes.is_hybrid() {
331            info_hashes
332                .v2
333                .map(|v2| Id20(v2.0[..20].try_into().unwrap()))
334        } else {
335            None
336        };
337        let (dht_v2_peers_rx, dht_v6_v2_peers_rx) =
338            if let (true, Some(v2_id)) = (enable_dht, v2_as_v1) {
339                let rx4 = if let Some(ref dht) = dht {
340                    dht.get_peers(v2_id).await.ok()
341                } else {
342                    None
343                };
344                let rx6 = if let Some(ref dht6) = dht_v6 {
345                    dht6.get_peers(v2_id).await.ok()
346                } else {
347                    None
348                };
349                (rx4, rx6)
350            } else {
351                (None, None)
352            };
353
354        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
355        let download_bucket = crate::rate_limiter::TokenBucket::new(config.download_rate_limit);
356        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
357            0,
358            0,
359            0,
360            0,
361            config.upload_rate_limit,
362            config.download_rate_limit,
363        );
364
365        let super_seed = if config.super_seeding {
366            Some(crate::super_seed::SuperSeedState::new())
367        } else {
368            None
369        };
370        // M118: broadcast channel for Have distribution — capacity scales with torrent size
371        let (have_broadcast_tx, _) =
372            tokio::sync::broadcast::channel(std::cmp::max(128, num_pieces as usize / 4));
373        let is_share_mode = config.share_mode;
374
375        let (piece_ready_tx, _) = broadcast::channel(64);
376        let initial_have = chunk_tracker.bitfield().clone();
377        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(initial_have);
378        let stream_read_semaphore =
379            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
380
381        let choker = Choker::with_algorithms(
382            4,
383            config.seed_choking_algorithm,
384            config.choking_algorithm,
385            config.upload_rate_limit,
386            2,
387            20,
388        );
389
390        // M96: Wire hash pool into disk handle for V1-only torrents
391        let mut disk = disk;
392        if matches!(version, irontide_core::TorrentVersion::V1Only)
393            && let Some(pool) = &hash_pool
394        {
395            disk.set_hash_pool(pool.clone());
396            disk.set_hash_result_tx(hash_result_tx.clone());
397        }
398
399        // M116: Pre-compute file->piece mapping for zero-alloc completion checks.
400        let cached_files = Some(build_cached_file_info(&meta, &lengths));
401
402        let actor = TorrentActor {
403            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
404                config.lock_warn_threshold_ms,
405            ),
406            config,
407            info_hash: meta.info_hash,
408            our_peer_id,
409            state: TorrentState::Downloading,
410            disk: Some(disk),
411            disk_manager,
412            chunk_tracker: Some(chunk_tracker),
413            lengths: Some(lengths),
414            num_pieces,
415            streaming_pieces: BTreeSet::new(),
416            time_critical_pieces: BTreeSet::new(),
417            streaming_cursors: Vec::new(),
418            piece_ready_tx,
419            have_watch_tx,
420            have_watch_rx,
421            stream_read_semaphore,
422            file_priorities,
423            wanted_pieces,
424            end_game: EndGame::new(),
425            peers: HashMap::new(),
426            cached_peer_rates: FxHashMap::default(),
427            refill_notify: Arc::new(tokio::sync::Notify::new()),
428            atomic_states: None,
429            block_maps: None,
430            steal_candidates: None,
431            last_steal_populate: Instant::now(),
432            piece_write_guards: None,
433            snapshot_dirty: false,
434            availability_snapshot: None,
435            snapshot_generation: 0,
436            piece_owner: Vec::new(),
437            peer_slab: crate::piece_reservation::PeerSlab::new(),
438            availability: Vec::new(),
439            priority_pieces: BTreeSet::new(),
440            max_in_flight: 512,
441            reservation_notify: None,
442            choker,
443            user_seed_mode: false,
444            max_connections: 0,
445            peer_states: None,
446            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
447            connect_permits: HashMap::new(),
448            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
449                std::collections::HashSet::new(),
450            )),
451            connect_rx: None,
452            metadata_downloader: None,
453            downloaded: 0,
454            uploaded: 0,
455            checking_progress: 0.0,
456            total_download: 0,
457            total_upload: 0,
458            total_failed_bytes: 0,
459            total_redundant_bytes: 0,
460            added_time: std::time::SystemTime::now()
461                .duration_since(std::time::UNIX_EPOCH)
462                .map(|d| d.as_secs() as i64)
463                .unwrap_or(0),
464            completed_time: 0,
465            last_download: 0,
466            last_upload: 0,
467            last_seen_complete: 0,
468            active_duration: 0,
469            finished_duration: 0,
470            seeding_duration: 0,
471            active_since: Some(std::time::Instant::now()),
472            state_duration_since: None,
473            started_at: std::time::Instant::now(),
474            moving_storage: false,
475            has_incoming: false,
476            need_save_resume: false,
477            error: String::new(),
478            error_file: -1,
479            cmd_rx,
480            event_tx,
481            event_rx,
482            write_error_rx,
483            write_error_tx,
484            verify_result_rx,
485            verify_result_tx,
486            pending_verify: HashSet::new(),
487            piece_generations: vec![0u64; num_pieces as usize],
488            hash_result_rx,
489            hash_result_tx,
490            meta: Some(meta),
491            cached_files,
492            listener,
493            utp_socket,
494            utp_socket_v6,
495            tracker_manager,
496            tracker_result_rx: None,
497            dht: if enable_dht { dht } else { None },
498            dht_v6: if enable_dht { dht_v6 } else { None },
499            dht_peers_rx,
500            dht_v6_peers_rx,
501            dht_v6_empty_count: 0,
502            dht_v6_last_retry: None,
503            alert_tx,
504            alert_mask,
505            upload_bucket,
506            download_bucket,
507            global_upload_bucket,
508            global_download_bucket,
509            slot_tuner,
510            upload_bytes_interval: 0,
511            peak_download_rate: 0,
512            web_seeds: HashMap::new(),
513            banned_web_seeds: HashSet::new(),
514            web_seed_in_flight: HashMap::new(),
515            super_seed,
516            have_broadcast_tx,
517            suggested_to_peers: HashMap::new(),
518            predictive_have_sent: HashSet::new(),
519
520            ban_manager,
521            ip_filter,
522            piece_contributors: HashMap::new(),
523            parole_pieces: HashMap::new(),
524            external_ip: None,
525            share_lru: std::collections::VecDeque::new(),
526            share_max_pieces: if is_share_mode { 64 } else { 0 },
527            plugins,
528            hash_picker,
529            version,
530            meta_v2,
531            info_hashes,
532            dht_v2_peers_rx,
533            dht_v6_v2_peers_rx,
534            magnet_selected_files: None,
535            sam_session,
536            i2p_accept_rx: None,
537            i2p_peer_counter: 0,
538            i2p_destinations: HashMap::new(),
539            ssl_manager,
540            rate_limiter_set,
541            auto_sequential_active: false,
542            factory,
543            hash_pool_ref: hash_pool,
544            connect_attempts: 0,
545            connect_failures: 0,
546            choke_rotations: 0,
547            inflight_started: Vec::new(),
548            completed_piece_times: std::collections::VecDeque::new(),
549            piece_steals: 0,
550            holepunch_cooldowns: HashMap::new(),
551            holepunch_pending: Vec::new(),
552        };
553
554        let spawn_info_hash = actor.info_hash;
555        let join_handle = tokio::spawn(actor.run());
556        // Monitor the actor task so panics/exits are logged instead of silently swallowed.
557        tokio::spawn(async move {
558            match join_handle.await {
559                Ok(()) => {
560                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
561                }
562                Err(e) if e.is_panic() => {
563                    let panic_payload = e.into_panic();
564                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
565                        (*s).to_string()
566                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
567                        s.clone()
568                    } else {
569                        "unknown panic payload".to_string()
570                    };
571                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
572                }
573                Err(e) => {
574                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
575                }
576            }
577        });
578        Ok(TorrentHandle { cmd_tx })
579    }
580
581    /// Create a torrent session from a magnet link (metadata fetched via BEP 9).
582    #[allow(clippy::too_many_arguments)]
583    pub(crate) async fn from_magnet(
584        magnet: Magnet,
585        disk_manager: DiskManagerHandle,
586        config: TorrentConfig,
587        dht: Option<DhtHandle>,
588        dht_v6: Option<DhtHandle>,
589        global_upload_bucket: Option<SharedBucket>,
590        global_download_bucket: Option<SharedBucket>,
591        slot_tuner: crate::slot_tuner::SlotTuner,
592        alert_tx: broadcast::Sender<Alert>,
593        alert_mask: Arc<AtomicU32>,
594        utp_socket: Option<irontide_utp::UtpSocket>,
595        utp_socket_v6: Option<irontide_utp::UtpSocket>,
596        ban_manager: crate::session::SharedBanManager,
597        ip_filter: crate::session::SharedIpFilter,
598        plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
599        sam_session: Option<Arc<crate::i2p::SamSession>>,
600        ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
601        factory: Arc<crate::transport::NetworkFactory>,
602        hash_pool: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
603    ) -> crate::Result<Self> {
604        let (cmd_tx, cmd_rx) = mpsc::channel(256);
605        let (event_tx, event_rx) = mpsc::channel(2048);
606        let (write_error_tx, write_error_rx) = mpsc::channel(64);
607        let (verify_result_tx, verify_result_rx) = mpsc::channel(1024);
608        // M96: Dummy channel — replaced when metadata arrives and num_pieces is known
609        let (hash_result_tx, hash_result_rx) = mpsc::channel(1);
610        let our_peer_id = if config.anonymous_mode {
611            PeerId::generate_anonymous().0
612        } else {
613            PeerId::generate().0
614        };
615
616        // Try dual-stack [::]:port first, fall back to IPv4-only
617        let listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
618            .bind_tcp(SocketAddr::from((
619                std::net::Ipv6Addr::UNSPECIFIED,
620                config.listen_port,
621            )))
622            .await
623        {
624            Ok(l) => Some(l),
625            Err(_) => factory
626                .bind_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen_port)))
627                .await
628                .ok(),
629        };
630        // Note: DSCP on listener is skipped for transport-abstracted sockets (no raw fd)
631
632        let mut tracker_manager = TrackerManager::empty(
633            magnet.info_hash(),
634            our_peer_id,
635            config.listen_port,
636            config.peer_dscp,
637            config.anonymous_mode,
638        );
639        // Add tracker URLs from the magnet link (BEP 9 §3.1)
640        for url in &magnet.trackers {
641            tracker_manager.add_tracker_url(url);
642        }
643
644        // BEP 7: include our I2P destination in tracker announces
645        if let Some(ref sam) = sam_session {
646            tracker_manager.set_i2p_destination(Some(sam.destination().to_base64()));
647        }
648
649        let enable_dht = config.enable_dht;
650
651        // Start DHT peer discovery if enabled and available
652        let dht_peers_rx = if enable_dht {
653            if let Some(ref dht) = dht {
654                match dht.get_peers(magnet.info_hash()).await {
655                    Ok(rx) => Some(rx),
656                    Err(e) => {
657                        warn!("failed to start DHT v4 get_peers: {e}");
658                        None
659                    }
660                }
661            } else {
662                None
663            }
664        } else {
665            None
666        };
667
668        let dht_v6_peers_rx = if enable_dht {
669            if let Some(ref dht6) = dht_v6 {
670                match dht6.get_peers(magnet.info_hash()).await {
671                    Ok(rx) => Some(rx),
672                    Err(e) => {
673                        debug!("failed to start DHT v6 get_peers: {e}");
674                        None
675                    }
676                }
677            } else {
678                None
679            }
680        } else {
681            None
682        };
683
684        let upload_bucket = crate::rate_limiter::TokenBucket::new(config.upload_rate_limit);
685        let download_bucket = crate::rate_limiter::TokenBucket::new(config.download_rate_limit);
686        let rate_limiter_set = crate::rate_limiter::RateLimiterSet::new(
687            0,
688            0,
689            0,
690            0,
691            config.upload_rate_limit,
692            config.download_rate_limit,
693        );
694
695        let super_seed = if config.super_seeding {
696            Some(crate::super_seed::SuperSeedState::new())
697        } else {
698            None
699        };
700        // M118: broadcast channel — start with min capacity for magnet (resized on metadata)
701        let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128);
702        let is_share_mode = config.share_mode;
703        let magnet_selected_files = magnet.selected_files.clone();
704        let info_hashes = magnet.info_hashes.clone();
705
706        let (piece_ready_tx, _) = broadcast::channel(64);
707        let (have_watch_tx, have_watch_rx) = tokio::sync::watch::channel(Bitfield::new(0));
708        let stream_read_semaphore =
709            crate::streaming::stream_read_semaphore(config.max_concurrent_stream_reads);
710
711        let choker = Choker::with_algorithms(
712            4,
713            config.seed_choking_algorithm,
714            config.choking_algorithm,
715            config.upload_rate_limit,
716            2,
717            20,
718        );
719
720        let actor = TorrentActor {
721            lock_timing: crate::timed_lock::LockTimingSettings::from_ms(
722                config.lock_warn_threshold_ms,
723            ),
724            config,
725            info_hash: magnet.info_hash(),
726            our_peer_id,
727            state: TorrentState::FetchingMetadata,
728            disk: None,
729            disk_manager,
730            chunk_tracker: None,
731            lengths: None,
732            num_pieces: 0,
733            streaming_pieces: BTreeSet::new(),
734            time_critical_pieces: BTreeSet::new(),
735            streaming_cursors: Vec::new(),
736            piece_ready_tx,
737            have_watch_tx,
738            have_watch_rx,
739            stream_read_semaphore,
740            file_priorities: Vec::new(),
741            wanted_pieces: Bitfield::new(0),
742            end_game: EndGame::new(),
743            peers: HashMap::new(),
744            cached_peer_rates: FxHashMap::default(),
745            refill_notify: Arc::new(tokio::sync::Notify::new()),
746            atomic_states: None,
747            block_maps: None,
748            steal_candidates: None,
749            last_steal_populate: Instant::now(),
750            piece_write_guards: None,
751            snapshot_dirty: false,
752            availability_snapshot: None,
753            snapshot_generation: 0,
754            piece_owner: Vec::new(),
755            peer_slab: crate::piece_reservation::PeerSlab::new(),
756            availability: Vec::new(),
757            priority_pieces: BTreeSet::new(),
758            max_in_flight: 512,
759            reservation_notify: None,
760            choker,
761            user_seed_mode: false,
762            max_connections: 0,
763            peer_states: None,
764            connect_semaphore: Arc::new(tokio::sync::Semaphore::new(0)),
765            connect_permits: HashMap::new(),
766            live_outgoing_peers: std::sync::Arc::new(parking_lot::RwLock::new(
767                std::collections::HashSet::new(),
768            )),
769            connect_rx: None,
770            metadata_downloader: Some(MetadataDownloader::new(magnet.info_hash())),
771            downloaded: 0,
772            uploaded: 0,
773            checking_progress: 0.0,
774            total_download: 0,
775            total_upload: 0,
776            total_failed_bytes: 0,
777            total_redundant_bytes: 0,
778            added_time: std::time::SystemTime::now()
779                .duration_since(std::time::UNIX_EPOCH)
780                .map(|d| d.as_secs() as i64)
781                .unwrap_or(0),
782            completed_time: 0,
783            last_download: 0,
784            last_upload: 0,
785            last_seen_complete: 0,
786            active_duration: 0,
787            finished_duration: 0,
788            seeding_duration: 0,
789            active_since: Some(std::time::Instant::now()),
790            state_duration_since: None,
791            started_at: std::time::Instant::now(),
792            moving_storage: false,
793            has_incoming: false,
794            need_save_resume: false,
795            error: String::new(),
796            error_file: -1,
797            cmd_rx,
798            event_tx,
799            event_rx,
800            write_error_rx,
801            write_error_tx,
802            verify_result_rx,
803            verify_result_tx,
804            pending_verify: HashSet::new(),
805            piece_generations: Vec::new(),
806            hash_result_rx,
807            hash_result_tx,
808            meta: None,
809            cached_files: None,
810            listener,
811            utp_socket,
812            utp_socket_v6,
813            tracker_manager,
814            tracker_result_rx: None,
815            dht: if enable_dht { dht } else { None },
816            dht_v6: if enable_dht { dht_v6 } else { None },
817            dht_peers_rx,
818            dht_v6_peers_rx,
819            dht_v6_empty_count: 0,
820            dht_v6_last_retry: None,
821            alert_tx,
822            alert_mask,
823            upload_bucket,
824            download_bucket,
825            global_upload_bucket,
826            global_download_bucket,
827            slot_tuner,
828            upload_bytes_interval: 0,
829            peak_download_rate: 0,
830            web_seeds: HashMap::new(),
831            banned_web_seeds: HashSet::new(),
832            web_seed_in_flight: HashMap::new(),
833            super_seed,
834            have_broadcast_tx,
835            suggested_to_peers: HashMap::new(),
836            predictive_have_sent: HashSet::new(),
837
838            ban_manager,
839            ip_filter,
840            piece_contributors: HashMap::new(),
841            parole_pieces: HashMap::new(),
842            external_ip: None,
843            share_lru: std::collections::VecDeque::new(),
844            share_max_pieces: if is_share_mode { 64 } else { 0 },
845            plugins,
846            hash_picker: None,
847            version: irontide_core::TorrentVersion::V1Only,
848            meta_v2: None,
849            info_hashes,
850            dht_v2_peers_rx: None,
851            dht_v6_v2_peers_rx: None,
852            magnet_selected_files,
853            sam_session,
854            i2p_accept_rx: None,
855            i2p_peer_counter: 0,
856            i2p_destinations: HashMap::new(),
857            ssl_manager,
858            rate_limiter_set,
859            auto_sequential_active: false,
860            factory,
861            hash_pool_ref: hash_pool,
862            connect_attempts: 0,
863            connect_failures: 0,
864            choke_rotations: 0,
865            inflight_started: Vec::new(),
866            completed_piece_times: std::collections::VecDeque::new(),
867            piece_steals: 0,
868            holepunch_cooldowns: HashMap::new(),
869            holepunch_pending: Vec::new(),
870        };
871
872        let spawn_info_hash = actor.info_hash;
873        let join_handle = tokio::spawn(actor.run());
874        tokio::spawn(async move {
875            match join_handle.await {
876                Ok(()) => {
877                    tracing::warn!(%spawn_info_hash, "torrent actor exited cleanly");
878                }
879                Err(e) if e.is_panic() => {
880                    let panic_payload = e.into_panic();
881                    let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
882                        (*s).to_string()
883                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {
884                        s.clone()
885                    } else {
886                        "unknown panic payload".to_string()
887                    };
888                    tracing::error!(%spawn_info_hash, "torrent actor PANICKED: {msg}");
889                }
890                Err(e) => {
891                    tracing::error!(%spawn_info_hash, "torrent actor task error: {e}");
892                }
893            }
894        });
895        Ok(TorrentHandle { cmd_tx })
896    }
897
898    /// Send an incoming peer (routed by the session) to this torrent.
899    pub(crate) async fn send_incoming_peer(
900        &self,
901        stream: crate::transport::BoxedStream,
902        addr: SocketAddr,
903    ) -> crate::Result<()> {
904        self.cmd_tx
905            .send(TorrentCommand::IncomingPeer { stream, addr })
906            .await
907            .map_err(|_| crate::Error::Shutdown)
908    }
909
910    /// Query current torrent statistics.
911    pub async fn stats(&self) -> crate::Result<TorrentStats> {
912        let (tx, rx) = oneshot::channel();
913        self.cmd_tx
914            .send(TorrentCommand::Stats { reply: tx })
915            .await
916            .map_err(|_| crate::Error::Shutdown)?;
917        rx.await.map_err(|_| crate::Error::Shutdown)
918    }
919
920    /// Add peer addresses to the available-peer pool.
921    pub async fn add_peers(&self, peers: Vec<SocketAddr>, source: PeerSource) -> crate::Result<()> {
922        self.cmd_tx
923            .send(TorrentCommand::AddPeers { peers, source })
924            .await
925            .map_err(|_| crate::Error::Shutdown)
926    }
927
928    /// Pause the torrent session (disconnect peers, announce Stopped).
929    pub async fn pause(&self) -> crate::Result<()> {
930        self.cmd_tx
931            .send(TorrentCommand::Pause)
932            .await
933            .map_err(|_| crate::Error::Shutdown)
934    }
935
936    /// Resume a paused torrent session (reconnect, announce Started).
937    pub async fn resume(&self) -> crate::Result<()> {
938        self.cmd_tx
939            .send(TorrentCommand::Resume)
940            .await
941            .map_err(|_| crate::Error::Shutdown)
942    }
943
944    /// Gracefully shut down the torrent session.
945    pub async fn shutdown(&self) -> crate::Result<()> {
946        // Best-effort send with timeout — if the channel is full or closed,
947        // the actor will exit when all senders are dropped anyway.
948        let _ = tokio::time::timeout(
949            std::time::Duration::from_secs(5),
950            self.cmd_tx.send(TorrentCommand::Shutdown),
951        )
952        .await;
953        Ok(())
954    }
955
956    /// Snapshot current torrent state into libtorrent-compatible resume data.
957    pub async fn save_resume_data(&self) -> crate::Result<irontide_core::FastResumeData> {
958        let (tx, rx) = oneshot::channel();
959        self.cmd_tx
960            .send(TorrentCommand::SaveResumeData { reply: tx })
961            .await
962            .map_err(|_| crate::Error::Shutdown)?;
963        rx.await.map_err(|_| crate::Error::Shutdown)?
964    }
965
966    /// Clear the `need_save_resume` dirty flag after a successful file save.
967    pub(crate) async fn clear_save_resume_flag(&self) -> crate::Result<()> {
968        self.cmd_tx
969            .send(TorrentCommand::ClearSaveResumeFlag)
970            .await
971            .map_err(|_| crate::Error::Shutdown)
972    }
973
974    /// Restore a piece bitmap from resume data (M161 Phase 4).
975    ///
976    /// Replaces the chunk tracker's bitfield with the provided raw piece bytes.
977    /// Returns an error if the bitfield length does not match the torrent's
978    /// piece count or if the chunk tracker is not yet initialized.
979    ///
980    /// # Errors
981    ///
982    /// Returns [`crate::Error::Shutdown`] if the torrent actor has stopped.
983    /// Returns [`crate::Error::InvalidSettings`] if the bitfield is invalid.
984    pub(crate) async fn restore_resume_bitmap(&self, pieces: Vec<u8>) -> crate::Result<()> {
985        let (tx, rx) = oneshot::channel();
986        self.cmd_tx
987            .send(TorrentCommand::RestoreResumeBitmap { pieces, reply: tx })
988            .await
989            .map_err(|_| crate::Error::Shutdown)?;
990        rx.await.map_err(|_| crate::Error::Shutdown)?
991    }
992
993    /// Set the download priority for a specific file.
994    pub async fn set_file_priority(
995        &self,
996        index: usize,
997        priority: irontide_core::FilePriority,
998    ) -> crate::Result<()> {
999        let (tx, rx) = oneshot::channel();
1000        self.cmd_tx
1001            .send(TorrentCommand::SetFilePriority {
1002                index,
1003                priority,
1004                reply: tx,
1005            })
1006            .await
1007            .map_err(|_| crate::Error::Shutdown)?;
1008        rx.await.map_err(|_| crate::Error::Shutdown)?
1009    }
1010
1011    /// Get the current per-file priorities.
1012    pub async fn file_priorities(&self) -> crate::Result<Vec<irontide_core::FilePriority>> {
1013        let (tx, rx) = oneshot::channel();
1014        self.cmd_tx
1015            .send(TorrentCommand::FilePriorities { reply: tx })
1016            .await
1017            .map_err(|_| crate::Error::Shutdown)?;
1018        rx.await.map_err(|_| crate::Error::Shutdown)
1019    }
1020
1021    /// Get the list of all configured trackers with their status.
1022    pub async fn tracker_list(&self) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
1023        let (tx, rx) = oneshot::channel();
1024        self.cmd_tx
1025            .send(TorrentCommand::TrackerList { reply: tx })
1026            .await
1027            .map_err(|_| crate::Error::Shutdown)?;
1028        rx.await.map_err(|_| crate::Error::Shutdown)
1029    }
1030
1031    /// Force all trackers to re-announce immediately.
1032    pub async fn force_reannounce(&self) -> crate::Result<()> {
1033        self.cmd_tx
1034            .send(TorrentCommand::ForceReannounce)
1035            .await
1036            .map_err(|_| crate::Error::Shutdown)
1037    }
1038
1039    /// Scrape trackers for seeder/leecher counts.
1040    pub async fn scrape(&self) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
1041        let (tx, rx) = oneshot::channel();
1042        self.cmd_tx
1043            .send(TorrentCommand::Scrape { reply: tx })
1044            .await
1045            .map_err(|_| crate::Error::Shutdown)?;
1046        rx.await.map_err(|_| crate::Error::Shutdown)
1047    }
1048
1049    /// Open a streaming reader for a file within the torrent.
1050    pub async fn open_file(
1051        &self,
1052        file_index: usize,
1053    ) -> crate::Result<crate::streaming::FileStream> {
1054        let (tx, rx) = oneshot::channel();
1055        self.cmd_tx
1056            .send(TorrentCommand::OpenFile {
1057                file_index,
1058                reply: tx,
1059            })
1060            .await
1061            .map_err(|_| crate::Error::Shutdown)?;
1062        let handle = rx.await.map_err(|_| crate::Error::Shutdown)??;
1063        Ok(crate::streaming::FileStream::from_handle(handle))
1064    }
1065
1066    /// Update the external IP for BEP 40 peer priority sorting.
1067    pub(crate) async fn update_external_ip(&self, ip: std::net::IpAddr) -> crate::Result<()> {
1068        self.cmd_tx
1069            .send(TorrentCommand::UpdateExternalIp { ip })
1070            .await
1071            .map_err(|_| crate::Error::Shutdown)
1072    }
1073
1074    /// Move torrent data files to a new download directory.
1075    ///
1076    /// Relocates existing files (rename or copy+delete), re-registers storage
1077    /// with the disk manager, and fires a `StorageMoved` alert on success.
1078    pub async fn move_storage(&self, new_path: std::path::PathBuf) -> crate::Result<()> {
1079        let (tx, rx) = oneshot::channel();
1080        self.cmd_tx
1081            .send(TorrentCommand::MoveStorage {
1082                new_path,
1083                reply: tx,
1084            })
1085            .await
1086            .map_err(|_| crate::Error::Shutdown)?;
1087        rx.await.map_err(|_| crate::Error::Shutdown)?
1088    }
1089
1090    /// Set the per-torrent download rate limit in bytes/sec (0 = unlimited).
1091    pub async fn set_download_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1092        let (tx, rx) = oneshot::channel();
1093        self.cmd_tx
1094            .send(TorrentCommand::SetDownloadLimit {
1095                bytes_per_sec,
1096                reply: tx,
1097            })
1098            .await
1099            .map_err(|_| crate::Error::Shutdown)?;
1100        rx.await.map_err(|_| crate::Error::Shutdown)
1101    }
1102
1103    /// Set the per-torrent upload rate limit in bytes/sec (0 = unlimited).
1104    pub async fn set_upload_limit(&self, bytes_per_sec: u64) -> crate::Result<()> {
1105        let (tx, rx) = oneshot::channel();
1106        self.cmd_tx
1107            .send(TorrentCommand::SetUploadLimit {
1108                bytes_per_sec,
1109                reply: tx,
1110            })
1111            .await
1112            .map_err(|_| crate::Error::Shutdown)?;
1113        rx.await.map_err(|_| crate::Error::Shutdown)
1114    }
1115
1116    /// Get the current per-torrent download rate limit in bytes/sec (0 = unlimited).
1117    pub async fn download_limit(&self) -> crate::Result<u64> {
1118        let (tx, rx) = oneshot::channel();
1119        self.cmd_tx
1120            .send(TorrentCommand::DownloadLimit { reply: tx })
1121            .await
1122            .map_err(|_| crate::Error::Shutdown)?;
1123        rx.await.map_err(|_| crate::Error::Shutdown)
1124    }
1125
1126    /// Get the current per-torrent upload rate limit in bytes/sec (0 = unlimited).
1127    pub async fn upload_limit(&self) -> crate::Result<u64> {
1128        let (tx, rx) = oneshot::channel();
1129        self.cmd_tx
1130            .send(TorrentCommand::UploadLimit { reply: tx })
1131            .await
1132            .map_err(|_| crate::Error::Shutdown)?;
1133        rx.await.map_err(|_| crate::Error::Shutdown)
1134    }
1135
1136    /// Enable or disable sequential (in-order) piece downloading.
1137    pub async fn set_sequential_download(&self, enabled: bool) -> crate::Result<()> {
1138        let (tx, rx) = oneshot::channel();
1139        self.cmd_tx
1140            .send(TorrentCommand::SetSequentialDownload { enabled, reply: tx })
1141            .await
1142            .map_err(|_| crate::Error::Shutdown)?;
1143        rx.await.map_err(|_| crate::Error::Shutdown)
1144    }
1145
1146    /// Query whether sequential downloading is enabled.
1147    pub async fn is_sequential_download(&self) -> crate::Result<bool> {
1148        let (tx, rx) = oneshot::channel();
1149        self.cmd_tx
1150            .send(TorrentCommand::IsSequentialDownload { reply: tx })
1151            .await
1152            .map_err(|_| crate::Error::Shutdown)?;
1153        rx.await.map_err(|_| crate::Error::Shutdown)
1154    }
1155
1156    /// Enable or disable BEP 16 super seeding mode.
1157    pub async fn set_super_seeding(&self, enabled: bool) -> crate::Result<()> {
1158        let (tx, rx) = oneshot::channel();
1159        self.cmd_tx
1160            .send(TorrentCommand::SetSuperSeeding { enabled, reply: tx })
1161            .await
1162            .map_err(|_| crate::Error::Shutdown)?;
1163        rx.await.map_err(|_| crate::Error::Shutdown)
1164    }
1165
1166    /// Query whether BEP 16 super seeding mode is enabled.
1167    pub async fn is_super_seeding(&self) -> crate::Result<bool> {
1168        let (tx, rx) = oneshot::channel();
1169        self.cmd_tx
1170            .send(TorrentCommand::IsSuperSeeding { reply: tx })
1171            .await
1172            .map_err(|_| crate::Error::Shutdown)?;
1173        rx.await.map_err(|_| crate::Error::Shutdown)
1174    }
1175
1176    /// Enable or disable user-requested seed-only mode (M159).
1177    ///
1178    /// When `enabled` is `true`, the actor stops scheduling new block requests
1179    /// and cancels all in-flight requests, but keeps existing peers connected
1180    /// and continues serving uploads. Toggling back to `false` restores normal
1181    /// piece scheduling.
1182    ///
1183    /// # Errors
1184    ///
1185    /// Returns [`crate::Error::Shutdown`] if the torrent actor has terminated.
1186    pub async fn set_seed_mode(&self, enabled: bool) -> crate::Result<()> {
1187        let (tx, rx) = oneshot::channel();
1188        self.cmd_tx
1189            .send(TorrentCommand::SetSeedMode { enabled, reply: tx })
1190            .await
1191            .map_err(|_| crate::Error::Shutdown)?;
1192        rx.await.map_err(|_| crate::Error::Shutdown)
1193    }
1194
1195    /// Add a new tracker URL to this torrent (fire-and-forget).
1196    ///
1197    /// The URL is validated and deduplicated by the tracker manager.
1198    pub async fn add_tracker(&self, url: String) -> crate::Result<()> {
1199        self.cmd_tx
1200            .send(TorrentCommand::AddTracker { url })
1201            .await
1202            .map_err(|_| crate::Error::Shutdown)
1203    }
1204
1205    /// Replace all tracker URLs for this torrent.
1206    pub async fn replace_trackers(&self, urls: Vec<String>) -> crate::Result<()> {
1207        let (tx, rx) = oneshot::channel();
1208        self.cmd_tx
1209            .send(TorrentCommand::ReplaceTrackers { urls, reply: tx })
1210            .await
1211            .map_err(|_| crate::Error::Shutdown)?;
1212        rx.await.map_err(|_| crate::Error::Shutdown)
1213    }
1214
1215    /// Trigger a full piece verification (force recheck).
1216    ///
1217    /// Transitions the torrent through `Checking` state, clears all piece
1218    /// completion data, re-verifies every piece against its hash, then
1219    /// transitions to `Seeding` (all valid) or `Downloading` (some missing).
1220    /// Returns after the check is complete.
1221    pub async fn force_recheck(&self) -> crate::Result<()> {
1222        let (tx, rx) = oneshot::channel();
1223        self.cmd_tx
1224            .send(TorrentCommand::ForceRecheck { reply: tx })
1225            .await
1226            .map_err(|_| crate::Error::Shutdown)?;
1227        rx.await.map_err(|_| crate::Error::Shutdown)?
1228    }
1229
1230    /// Rename a file within the torrent on disk.
1231    ///
1232    /// Changes the filename of the specified file (by index) to `new_name`.
1233    /// The file stays in the same directory; only the filename component changes.
1234    /// Fires a `FileRenamed` alert on success.
1235    pub async fn rename_file(&self, file_index: usize, new_name: String) -> crate::Result<()> {
1236        let (tx, rx) = oneshot::channel();
1237        self.cmd_tx
1238            .send(TorrentCommand::RenameFile {
1239                file_index,
1240                new_name,
1241                reply: tx,
1242            })
1243            .await
1244            .map_err(|_| crate::Error::Shutdown)?;
1245        rx.await.map_err(|_| crate::Error::Shutdown)?
1246    }
1247
1248    /// Route an incoming SSL peer (TLS already completed) to this torrent (M42).
1249    pub(crate) async fn spawn_ssl_peer(
1250        &self,
1251        addr: SocketAddr,
1252        stream: impl tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
1253    ) -> crate::Result<()> {
1254        self.cmd_tx
1255            .send(TorrentCommand::SpawnSslPeer {
1256                addr,
1257                stream: crate::types::BoxedAsyncStream(Box::new(stream)),
1258            })
1259            .await
1260            .map_err(|_| crate::Error::Shutdown)
1261    }
1262
1263    /// Set the per-torrent maximum number of connections (0 = use global default).
1264    pub async fn set_max_connections(&self, limit: usize) -> crate::Result<()> {
1265        let (tx, rx) = oneshot::channel();
1266        self.cmd_tx
1267            .send(TorrentCommand::SetMaxConnections { limit, reply: tx })
1268            .await
1269            .map_err(|_| crate::Error::Shutdown)?;
1270        rx.await.map_err(|_| crate::Error::Shutdown)
1271    }
1272
1273    /// Get the current per-torrent maximum connection limit (0 = use global default).
1274    pub async fn max_connections(&self) -> crate::Result<usize> {
1275        let (tx, rx) = oneshot::channel();
1276        self.cmd_tx
1277            .send(TorrentCommand::MaxConnections { reply: tx })
1278            .await
1279            .map_err(|_| crate::Error::Shutdown)?;
1280        rx.await.map_err(|_| crate::Error::Shutdown)
1281    }
1282
1283    /// Set the per-torrent maximum number of upload slots (unchoke slots).
1284    pub async fn set_max_uploads(&self, limit: usize) -> crate::Result<()> {
1285        let (tx, rx) = oneshot::channel();
1286        self.cmd_tx
1287            .send(TorrentCommand::SetMaxUploads { limit, reply: tx })
1288            .await
1289            .map_err(|_| crate::Error::Shutdown)?;
1290        rx.await.map_err(|_| crate::Error::Shutdown)
1291    }
1292
1293    /// Get the current per-torrent maximum upload slots (unchoke slots).
1294    pub async fn max_uploads(&self) -> crate::Result<usize> {
1295        let (tx, rx) = oneshot::channel();
1296        self.cmd_tx
1297            .send(TorrentCommand::MaxUploads { reply: tx })
1298            .await
1299            .map_err(|_| crate::Error::Shutdown)?;
1300        rx.await.map_err(|_| crate::Error::Shutdown)
1301    }
1302
1303    /// Get per-peer details for all connected peers.
1304    pub async fn get_peer_info(&self) -> crate::Result<Vec<PeerInfo>> {
1305        let (tx, rx) = oneshot::channel();
1306        self.cmd_tx
1307            .send(TorrentCommand::GetPeerInfo { reply: tx })
1308            .await
1309            .map_err(|_| crate::Error::Shutdown)?;
1310        rx.await.map_err(|_| crate::Error::Shutdown)
1311    }
1312
1313    /// Get in-flight piece download status (the download queue).
1314    pub async fn get_download_queue(&self) -> crate::Result<Vec<PartialPieceInfo>> {
1315        let (tx, rx) = oneshot::channel();
1316        self.cmd_tx
1317            .send(TorrentCommand::GetDownloadQueue { reply: tx })
1318            .await
1319            .map_err(|_| crate::Error::Shutdown)?;
1320        rx.await.map_err(|_| crate::Error::Shutdown)
1321    }
1322
1323    /// Check whether a specific piece has been downloaded.
1324    pub async fn have_piece(&self, index: u32) -> crate::Result<bool> {
1325        let (tx, rx) = oneshot::channel();
1326        self.cmd_tx
1327            .send(TorrentCommand::HavePiece { index, reply: tx })
1328            .await
1329            .map_err(|_| crate::Error::Shutdown)?;
1330        rx.await.map_err(|_| crate::Error::Shutdown)
1331    }
1332
1333    /// Get per-piece availability counts from connected peers.
1334    pub async fn piece_availability(&self) -> crate::Result<Vec<u32>> {
1335        let (tx, rx) = oneshot::channel();
1336        self.cmd_tx
1337            .send(TorrentCommand::PieceAvailability { reply: tx })
1338            .await
1339            .map_err(|_| crate::Error::Shutdown)?;
1340        rx.await.map_err(|_| crate::Error::Shutdown)
1341    }
1342
1343    /// Get per-file bytes-downloaded progress.
1344    pub async fn file_progress(&self) -> crate::Result<Vec<u64>> {
1345        let (tx, rx) = oneshot::channel();
1346        self.cmd_tx
1347            .send(TorrentCommand::FileProgress { reply: tx })
1348            .await
1349            .map_err(|_| crate::Error::Shutdown)?;
1350        rx.await.map_err(|_| crate::Error::Shutdown)
1351    }
1352
1353    /// Get the torrent's identity hashes (v1 and/or v2).
1354    pub async fn info_hashes(&self) -> crate::Result<irontide_core::InfoHashes> {
1355        let (tx, rx) = oneshot::channel();
1356        self.cmd_tx
1357            .send(TorrentCommand::InfoHashes { reply: tx })
1358            .await
1359            .map_err(|_| crate::Error::Shutdown)?;
1360        rx.await.map_err(|_| crate::Error::Shutdown)
1361    }
1362
1363    /// Get the full v1 metainfo, if available.
1364    ///
1365    /// Returns `None` for magnet links before metadata has been received.
1366    pub async fn torrent_file(&self) -> crate::Result<Option<TorrentMetaV1>> {
1367        let (tx, rx) = oneshot::channel();
1368        self.cmd_tx
1369            .send(TorrentCommand::TorrentFile { reply: tx })
1370            .await
1371            .map_err(|_| crate::Error::Shutdown)?;
1372        rx.await.map_err(|_| crate::Error::Shutdown)
1373    }
1374
1375    /// Get the full v2 metainfo, if available.
1376    ///
1377    /// Returns `None` if the torrent is not a v2/hybrid torrent, or for magnet
1378    /// links before metadata has been received.
1379    pub async fn torrent_file_v2(&self) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
1380        let (tx, rx) = oneshot::channel();
1381        self.cmd_tx
1382            .send(TorrentCommand::TorrentFileV2 { reply: tx })
1383            .await
1384            .map_err(|_| crate::Error::Shutdown)?;
1385        rx.await.map_err(|_| crate::Error::Shutdown)
1386    }
1387
1388    /// Force an immediate DHT announce for this torrent.
1389    ///
1390    /// Fire-and-forget at the torrent level — the DHT announce is best-effort.
1391    pub async fn force_dht_announce(&self) -> crate::Result<()> {
1392        self.cmd_tx
1393            .send(TorrentCommand::ForceDhtAnnounce)
1394            .await
1395            .map_err(|_| crate::Error::Shutdown)
1396    }
1397
1398    /// Read all data for a specific piece from disk.
1399    ///
1400    /// Returns the complete piece data as `Bytes`. The piece must have been
1401    /// downloaded already; use [`have_piece`](Self::have_piece) to check first.
1402    pub async fn read_piece(&self, index: u32) -> crate::Result<Bytes> {
1403        let (tx, rx) = oneshot::channel();
1404        self.cmd_tx
1405            .send(TorrentCommand::ReadPiece { index, reply: tx })
1406            .await
1407            .map_err(|_| crate::Error::Shutdown)?;
1408        rx.await.map_err(|_| crate::Error::Shutdown)?
1409    }
1410
1411    /// Flush the disk write cache, ensuring all buffered writes are persisted.
1412    pub async fn flush_cache(&self) -> crate::Result<()> {
1413        let (tx, rx) = oneshot::channel();
1414        self.cmd_tx
1415            .send(TorrentCommand::FlushCache { reply: tx })
1416            .await
1417            .map_err(|_| crate::Error::Shutdown)?;
1418        rx.await.map_err(|_| crate::Error::Shutdown)?
1419    }
1420
1421    /// Check whether this handle refers to a live torrent.
1422    ///
1423    /// Returns `false` after the torrent has been removed or shut down.
1424    /// This is a synchronous check on the channel state — no command dispatch.
1425    pub fn is_valid(&self) -> bool {
1426        !self.cmd_tx.is_closed()
1427    }
1428
1429    /// Clear any error state on the torrent and resume if it was paused due to error.
1430    pub async fn clear_error(&self) -> crate::Result<()> {
1431        self.cmd_tx
1432            .send(TorrentCommand::ClearError)
1433            .await
1434            .map_err(|_| crate::Error::Shutdown)
1435    }
1436
1437    /// Get per-file open/mode status based on the current torrent state.
1438    ///
1439    /// Returns one [`crate::types::FileStatus`] entry per file in the torrent.
1440    pub async fn file_status(&self) -> crate::Result<Vec<crate::types::FileStatus>> {
1441        let (tx, rx) = oneshot::channel();
1442        self.cmd_tx
1443            .send(TorrentCommand::FileStatus { reply: tx })
1444            .await
1445            .map_err(|_| crate::Error::Shutdown)?;
1446        rx.await.map_err(|_| crate::Error::Shutdown)
1447    }
1448
1449    /// Read the current torrent state as a [`TorrentFlags`] bitflag set.
1450    pub async fn flags(&self) -> crate::Result<crate::types::TorrentFlags> {
1451        let (tx, rx) = oneshot::channel();
1452        self.cmd_tx
1453            .send(TorrentCommand::Flags { reply: tx })
1454            .await
1455            .map_err(|_| crate::Error::Shutdown)?;
1456        rx.await.map_err(|_| crate::Error::Shutdown)
1457    }
1458
1459    /// Set (enable) the specified torrent flags.
1460    ///
1461    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1462    pub async fn set_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
1463        let (tx, rx) = oneshot::channel();
1464        self.cmd_tx
1465            .send(TorrentCommand::SetFlags { flags, reply: tx })
1466            .await
1467            .map_err(|_| crate::Error::Shutdown)?;
1468        rx.await.map_err(|_| crate::Error::Shutdown)
1469    }
1470
1471    /// Unset (disable) the specified torrent flags.
1472    ///
1473    /// Delegates to the underlying operations (pause/resume, sequential download, etc.).
1474    pub async fn unset_flags(&self, flags: crate::types::TorrentFlags) -> crate::Result<()> {
1475        let (tx, rx) = oneshot::channel();
1476        self.cmd_tx
1477            .send(TorrentCommand::UnsetFlags { flags, reply: tx })
1478            .await
1479            .map_err(|_| crate::Error::Shutdown)?;
1480        rx.await.map_err(|_| crate::Error::Shutdown)
1481    }
1482
1483    /// Immediately initiate a peer connection to the given address.
1484    ///
1485    /// Bypasses the normal peer selection queue — the connection attempt starts
1486    /// right away. Fire-and-forget: no reply is sent.
1487    pub async fn connect_peer(&self, addr: SocketAddr) -> crate::Result<()> {
1488        self.cmd_tx
1489            .send(TorrentCommand::ConnectPeer { addr })
1490            .await
1491            .map_err(|_| crate::Error::Shutdown)
1492    }
1493
1494    /// M147: Send pre-resolved metadata from the background resolver.
1495    ///
1496    /// Fire-and-forget: uses `try_send` to avoid blocking the resolver task.
1497    /// If the channel is full or closed, the pre-resolved metadata is silently
1498    /// discarded (the TorrentActor's own FetchingMetadata phase will handle it).
1499    pub(crate) fn send_pre_resolved_metadata(&self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
1500        let _ = self
1501            .cmd_tx
1502            .try_send(TorrentCommand::PreResolvedMetadata { info_bytes, peers });
1503    }
1504}
1505
1506// ---------------------------------------------------------------------------
1507// M116: Cached file metadata for zero-allocation piece completion checks
1508// ---------------------------------------------------------------------------
1509
1510/// Pre-computed file metadata for zero-allocation piece completion checks.
1511#[derive(Debug, Clone)]
1512pub(crate) struct CachedFileEntry {
1513    pub(crate) index: usize,
1514    #[allow(dead_code)] // Used in tests; retained for future diagnostics
1515    pub(crate) length: u64,
1516    pub(crate) first_piece: u32,
1517    pub(crate) last_piece: u32,
1518}
1519
1520/// Cached file-to-piece mapping, computed once at torrent registration.
1521#[derive(Debug, Clone)]
1522pub(crate) struct CachedFileInfo {
1523    pub(crate) entries: Vec<CachedFileEntry>,
1524}
1525
1526pub(crate) fn build_cached_file_info(meta: &TorrentMetaV1, lengths: &Lengths) -> CachedFileInfo {
1527    let piece_length = lengths.piece_length();
1528    let files = meta.info.files();
1529    let mut entries = Vec::with_capacity(files.len());
1530    let mut offset = 0u64;
1531    for (index, file) in files.iter().enumerate() {
1532        let first_piece = (offset / piece_length) as u32;
1533        let last_piece = if file.length == 0 {
1534            first_piece
1535        } else {
1536            ((offset + file.length - 1) / piece_length) as u32
1537        };
1538        entries.push(CachedFileEntry {
1539            index,
1540            length: file.length,
1541            first_piece,
1542            last_piece,
1543        });
1544        offset += file.length;
1545    }
1546    CachedFileInfo { entries }
1547}
1548
1549// ---------------------------------------------------------------------------
1550// TorrentActor — internal single-owner event loop
1551// ---------------------------------------------------------------------------
1552
1553pub(crate) struct TorrentActor {
1554    pub(crate) config: TorrentConfig,
1555    /// M120: Lock timing settings for hot-path diagnostics.
1556    pub(crate) lock_timing: crate::timed_lock::LockTimingSettings,
1557    pub(crate) info_hash: Id20,
1558    pub(crate) our_peer_id: Id20,
1559    pub(crate) state: TorrentState,
1560
1561    // Disk I/O (None in magnet mode until metadata arrives)
1562    pub(crate) disk: Option<DiskHandle>,
1563    pub(crate) disk_manager: DiskManagerHandle,
1564    pub(crate) chunk_tracker: Option<ChunkTracker>,
1565    pub(crate) lengths: Option<Lengths>,
1566    pub(crate) num_pieces: u32,
1567
1568    // Piece management
1569    pub(crate) file_priorities: Vec<FilePriority>,
1570    pub(crate) wanted_pieces: Bitfield,
1571    pub(crate) end_game: EndGame,
1572
1573    // Streaming (M28)
1574    pub(crate) streaming_pieces: BTreeSet<u32>,
1575    pub(crate) time_critical_pieces: BTreeSet<u32>,
1576    pub(crate) streaming_cursors: Vec<crate::streaming::StreamingCursor>,
1577    pub(crate) piece_ready_tx: broadcast::Sender<u32>,
1578    pub(crate) have_watch_tx: tokio::sync::watch::Sender<Bitfield>,
1579    pub(crate) have_watch_rx: tokio::sync::watch::Receiver<Bitfield>,
1580    pub(crate) stream_read_semaphore: Arc<tokio::sync::Semaphore>,
1581
1582    // Peer management
1583    pub(crate) peers: HashMap<SocketAddr, PeerState>,
1584    /// Cached peer download rates for piece stealing decisions.
1585    /// Refreshed on each periodic tick (~1s) instead of rebuilding per block.
1586    pub(crate) cached_peer_rates: FxHashMap<SocketAddr, f64>,
1587    /// Notify handle for reactive queue refill (legacy, unused in M73).
1588    #[allow(dead_code)]
1589    pub(crate) refill_notify: Arc<tokio::sync::Notify>,
1590    /// M93: Lock-free piece states (shared with peers via Arc).
1591    pub(crate) atomic_states: Option<Arc<crate::piece_reservation::AtomicPieceStates>>,
1592    /// M103: Shared block-level request/received bitmaps.
1593    pub(crate) block_maps: Option<Arc<BlockMaps>>,
1594    /// M103: Shared queue of stealable pieces.
1595    pub(crate) steal_candidates: Option<Arc<StealCandidates>>,
1596    /// M132: Last time we populated the steal queue with in-flight pieces.
1597    pub(crate) last_steal_populate: Instant,
1598    /// M120: Per-piece write guards to prevent steal/write races.
1599    pub(crate) piece_write_guards: Option<Arc<crate::piece_reservation::PieceWriteGuards>>,
1600    /// M103: Dirty flag for reactive snapshot rebuild.
1601    pub(crate) snapshot_dirty: bool,
1602    /// M93: Current availability snapshot (shared with peers via Arc).
1603    pub(crate) availability_snapshot: Option<Arc<crate::piece_reservation::AvailabilitySnapshot>>,
1604    /// M93: Snapshot generation counter.
1605    pub(crate) snapshot_generation: u64,
1606    /// M93: Maps piece index -> peer slab slot that owns it.
1607    pub(crate) piece_owner: Vec<Option<u16>>,
1608    /// M93: Arena-allocated peer tracking: slot <-> SocketAddr.
1609    pub(crate) peer_slab: crate::piece_reservation::PeerSlab,
1610    /// M93: Per-piece availability count.
1611    pub(crate) availability: Vec<u32>,
1612    /// M93: Priority pieces (streaming, time-critical).
1613    pub(crate) priority_pieces: BTreeSet<u32>,
1614    /// M93: Maximum in-flight pieces.
1615    pub(crate) max_in_flight: usize,
1616    /// Piece notify handle (for driver spawning).
1617    pub(crate) reservation_notify: Option<Arc<tokio::sync::Notify>>,
1618    pub(crate) choker: Choker,
1619    /// M159: User-requested seed-only mode flag.
1620    ///
1621    /// When `true`, the actor stops issuing new block requests (gating the
1622    /// `StartRequesting` dispatch sent to peer tasks) and cancels any
1623    /// in-flight requests. Uploads continue unaffected. Distinct from the
1624    /// naturally-complete seeding state tracked by `state == Seeding`.
1625    pub(crate) user_seed_mode: bool,
1626    /// Per-torrent connection limit override (0 = use config.max_peers).
1627    pub(crate) max_connections: usize,
1628    /// M137: Unified peer lifecycle tracker (replaces peers_connected + connect_backoff + peer_tx + unique_peers_attempted).
1629    pub(crate) peer_states: Option<Arc<crate::peer_states::PeerStates>>,
1630    /// M147: ConnectPool semaphore — gates connection attempts only.
1631    /// Permits are released on HandshakeComplete (not held for peer lifetime).
1632    pub(crate) connect_semaphore: Arc<tokio::sync::Semaphore>,
1633    /// M147: Maps peer address → permit holder. Permits are taken on HandshakeComplete
1634    /// to free ConnectPool slots. RAII cleanup on task exit handles failure cases.
1635    pub(crate) connect_permits:
1636        HashMap<SocketAddr, Arc<parking_lot::Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>>,
1637    /// M107: Receiver for connect requests from the adder task.
1638    pub(crate) connect_rx: Option<mpsc::Receiver<ConnectPeer>>,
1639
1640    // Metadata (for magnet links)
1641    pub(crate) metadata_downloader: Option<MetadataDownloader>,
1642
1643    // Parsed torrent meta (for piece hash verification)
1644    pub(crate) meta: Option<TorrentMetaV1>,
1645
1646    /// M116: Pre-computed file->piece mapping for zero-alloc completion checks.
1647    pub(crate) cached_files: Option<CachedFileInfo>,
1648
1649    // Stats
1650    pub(crate) downloaded: u64,
1651    pub(crate) uploaded: u64,
1652    pub(crate) checking_progress: f32,
1653    pub(crate) total_download: u64,
1654    pub(crate) total_upload: u64,
1655    pub(crate) total_failed_bytes: u64,
1656    pub(crate) total_redundant_bytes: u64,
1657    pub(crate) added_time: i64,
1658    pub(crate) completed_time: i64,
1659    pub(crate) last_download: i64,
1660    pub(crate) last_upload: i64,
1661    pub(crate) last_seen_complete: i64,
1662    pub(crate) active_duration: i64,
1663    pub(crate) finished_duration: i64,
1664    pub(crate) seeding_duration: i64,
1665    pub(crate) active_since: Option<std::time::Instant>,
1666    pub(crate) state_duration_since: Option<std::time::Instant>,
1667    #[allow(dead_code)] // M104: ConnectPhase removed; kept for future diagnostics
1668    pub(crate) started_at: std::time::Instant,
1669    pub(crate) moving_storage: bool,
1670    pub(crate) has_incoming: bool,
1671    pub(crate) need_save_resume: bool,
1672    pub(crate) error: String,
1673    pub(crate) error_file: i32,
1674
1675    // Channels
1676    pub(crate) cmd_rx: mpsc::Receiver<TorrentCommand>,
1677    pub(crate) event_tx: mpsc::Sender<PeerEvent>,
1678    pub(crate) event_rx: mpsc::Receiver<PeerEvent>,
1679
1680    // Async disk pipeline channels
1681    pub(crate) write_error_rx: mpsc::Receiver<crate::disk::DiskWriteError>,
1682    pub(crate) write_error_tx: mpsc::Sender<crate::disk::DiskWriteError>,
1683    pub(crate) verify_result_rx: mpsc::Receiver<crate::disk::VerifyResult>,
1684    pub(crate) verify_result_tx: mpsc::Sender<crate::disk::VerifyResult>,
1685    /// Pieces currently awaiting async verification — prevents duplicate
1686    /// verify tasks when end game or slow peers deliver duplicate blocks.
1687    pub(crate) pending_verify: HashSet<u32>,
1688    /// Generation counter per piece — increments on release/re-reserve.
1689    /// Used to detect stale hash results from the HashPool (M96).
1690    pub(crate) piece_generations: Vec<u64>,
1691    /// Receiver for hash pool results (M96).
1692    pub(crate) hash_result_rx: tokio::sync::mpsc::Receiver<crate::hash_pool::HashResult>,
1693    /// Sender for hash pool results — cloned into DiskHandle (M96).
1694    pub(crate) hash_result_tx: tokio::sync::mpsc::Sender<crate::hash_pool::HashResult>,
1695
1696    // TCP listener for incoming peer connections
1697    pub(crate) listener: Option<Box<dyn crate::transport::TransportListener>>,
1698
1699    // uTP socket for outbound connections (shared with session, cloned)
1700    pub(crate) utp_socket: Option<irontide_utp::UtpSocket>,
1701    // IPv6 uTP socket for outbound connections to IPv6 peers
1702    pub(crate) utp_socket_v6: Option<irontide_utp::UtpSocket>,
1703
1704    // Tracker management
1705    pub(crate) tracker_manager: TrackerManager,
1706    /// M143: Receiver for streaming tracker announce results.
1707    /// `Some` while a background announce is in-flight, `None` when idle.
1708    pub(crate) tracker_result_rx: Option<mpsc::Receiver<crate::tracker_manager::TrackerPeerBatch>>,
1709
1710    // DHT handles (shared, optional)
1711    pub(crate) dht: Option<DhtHandle>,
1712    pub(crate) dht_v6: Option<DhtHandle>,
1713    pub(crate) dht_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
1714    pub(crate) dht_v6_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
1715    /// Consecutive times the V6 DHT returned an empty table.
1716    /// After 30 failures (~3s at 100ms), stop retrying to avoid log spam.
1717    pub(crate) dht_v6_empty_count: u32,
1718    /// Timestamp of last V6 DHT retry attempt (M97).
1719    pub(crate) dht_v6_last_retry: Option<std::time::Instant>,
1720
1721    // Alert system (M15)
1722    pub(crate) alert_tx: broadcast::Sender<Alert>,
1723    pub(crate) alert_mask: Arc<AtomicU32>,
1724
1725    // Rate limiting (M14)
1726    pub(crate) upload_bucket: crate::rate_limiter::TokenBucket,
1727    pub(crate) download_bucket: crate::rate_limiter::TokenBucket,
1728    pub(crate) global_upload_bucket: Option<SharedBucket>,
1729    #[allow(dead_code)] // M73: rate limiting deferred to M74
1730    pub(crate) global_download_bucket: Option<SharedBucket>,
1731    pub(crate) slot_tuner: crate::slot_tuner::SlotTuner,
1732    pub(crate) upload_bytes_interval: u64,
1733
1734    /// Peak aggregate download rate observed (bytes/sec), for peer turnover cutoff.
1735    pub(crate) peak_download_rate: u64,
1736
1737    // Web seeding (M22)
1738    pub(crate) web_seeds: HashMap<String, mpsc::Sender<crate::web_seed::WebSeedCommand>>,
1739    pub(crate) banned_web_seeds: HashSet<String>,
1740    pub(crate) web_seed_in_flight: HashMap<u32, String>,
1741
1742    // BEP 16 super seeding (M23)
1743    pub(crate) super_seed: Option<crate::super_seed::SuperSeedState>,
1744    // M118: Broadcast channel for Have distribution (replaces HaveBuffer)
1745    pub(crate) have_broadcast_tx: tokio::sync::broadcast::Sender<u32>,
1746
1747    /// M44: pieces we've suggested to each peer (avoid re-suggesting)
1748    pub(crate) suggested_to_peers: HashMap<SocketAddr, HashSet<u32>>,
1749
1750    /// M44: pieces for which we've already sent predictive Have
1751    pub(crate) predictive_have_sent: HashSet<u32>,
1752
1753    // Smart banning (M25)
1754    pub(crate) ban_manager: crate::session::SharedBanManager,
1755    pub(crate) piece_contributors: HashMap<u32, HashSet<std::net::IpAddr>>,
1756    pub(crate) parole_pieces: HashMap<u32, crate::ban::ParoleState>,
1757
1758    // IP filtering (M29)
1759    pub(crate) ip_filter: crate::session::SharedIpFilter,
1760
1761    // BEP 40 peer priority (M32b)
1762    pub(crate) external_ip: Option<std::net::IpAddr>,
1763
1764    // Share mode (M32c): LRU tracker for in-memory piece relay.
1765    // Tracks which pieces are currently "live" (servable) in share mode.
1766    // Oldest pieces are evicted when capacity is reached.
1767    pub(crate) share_lru: std::collections::VecDeque<u32>,
1768    /// Max pieces to keep live in share mode (0 = share mode disabled).
1769    pub(crate) share_max_pieces: usize,
1770
1771    // Extension plugins (M32d)
1772    pub(crate) plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1773
1774    // BEP 52 v2/hybrid support (M34-M35)
1775    pub(crate) hash_picker: Option<irontide_core::HashPicker>,
1776    pub(crate) version: irontide_core::TorrentVersion,
1777    #[allow(dead_code)] // stored for hybrid torrent re-serialization (M35 Task 5)
1778    pub(crate) meta_v2: Option<irontide_core::TorrentMetaV2>,
1779
1780    /// Full info hashes for dual-swarm support (v1 + v2 for hybrid).
1781    pub(crate) info_hashes: irontide_core::InfoHashes,
1782
1783    /// Dual-swarm DHT peer receivers (v2 hash in hybrid torrents).
1784    pub(crate) dht_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
1785    pub(crate) dht_v6_v2_peers_rx: Option<mpsc::UnboundedReceiver<Vec<SocketAddr>>>,
1786
1787    /// BEP 53: deferred file selection from magnet `so=` parameter.
1788    /// Applied after metadata is received to set file priorities.
1789    pub(crate) magnet_selected_files: Option<Vec<irontide_core::FileSelection>>,
1790
1791    /// I2P SAM session for anonymous peer connections (M41).
1792    pub(crate) sam_session: Option<Arc<crate::i2p::SamSession>>,
1793
1794    /// Receiver for incoming I2P peer connections (M41).
1795    pub(crate) i2p_accept_rx: Option<mpsc::Receiver<crate::i2p::SamStream>>,
1796
1797    /// Counter for generating synthetic SocketAddr values for I2P peers (M41).
1798    pub(crate) i2p_peer_counter: u32,
1799
1800    /// Maps synthetic SocketAddr → I2pDestination for outbound I2P connects.
1801    pub(crate) i2p_destinations: HashMap<SocketAddr, crate::i2p::I2pDestination>,
1802
1803    /// SSL manager for SSL torrent certificate handling (M42).
1804    pub(crate) ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
1805
1806    /// Per-class rate limiting with mixed-mode (M45).
1807    pub(crate) rate_limiter_set: crate::rate_limiter::RateLimiterSet,
1808    /// Whether auto-sequential mode is currently active (hysteresis state).
1809    pub(crate) auto_sequential_active: bool,
1810    /// Network transport factory for TCP operations (M51).
1811    pub(crate) factory: Arc<crate::transport::NetworkFactory>,
1812    /// Shared hash pool for parallel piece verification (M96).
1813    pub(crate) hash_pool_ref: Option<std::sync::Arc<crate::hash_pool::HashPool>>,
1814    /// M108: Shared snapshot of connected peer addresses for PEX send tasks.
1815    pub(crate) live_outgoing_peers:
1816        std::sync::Arc<parking_lot::RwLock<std::collections::HashSet<SocketAddr>>>,
1817    /// M108: Total outbound connection attempts dispatched to peer adder.
1818    pub(crate) connect_attempts: u64,
1819    /// M108: Total connection failures (peers that disconnected).
1820    pub(crate) connect_failures: u64,
1821    /// M138: Total number of peers evicted by proactive choke rotation.
1822    pub(crate) choke_rotations: u64,
1823    /// M149: When each in-flight piece started downloading. Indexed by piece index.
1824    /// Set when piece_owner assigns a piece, cleared on verify/hash-fail.
1825    pub(crate) inflight_started: Vec<Option<Instant>>,
1826    /// M149: Rolling window of recent piece completion times for steal threshold.
1827    pub(crate) completed_piece_times: std::collections::VecDeque<Duration>,
1828    /// M149: Total number of piece-level steals performed.
1829    pub(crate) piece_steals: u64,
1830    /// M112: Tracks recent holepunch attempts to prevent retry storms.
1831    pub(crate) holepunch_cooldowns: HashMap<SocketAddr, Instant>,
1832    /// M112: Buffer for holepunch attempts (disconnect_peer is sync, try_holepunch is async).
1833    pub(crate) holepunch_pending: Vec<SocketAddr>,
1834}
1835
1836/// Maximum number of in-flight end-game requests per peer.
1837/// libtorrent continues full pipelining in end-game; we use a moderate
1838/// depth so that round-trip latency doesn't bottleneck throughput.
1839/// End-game pipeline depth: match normal mode (128 slots per peer).
1840/// Safe because the reactive per-block cascade was replaced with a 200ms
1841/// batch refill tick — raising depth no longer amplifies picker invocations.
1842pub(crate) const END_GAME_DEPTH: usize = 128;
1843
1844/// Minimum free pipeline slots before invoking the full piece picker in
1845/// `handle_piece_data()`.  Avoids running the 5-layer picker on every single
1846impl TorrentActor {
1847    /// Main event loop.
1848    async fn run(mut self) {
1849        // Verify existing pieces on startup (resume support)
1850        self.verify_existing_pieces().await;
1851
1852        // M93: Initialize lock-free piece states after verification
1853        // so we_have reflects already-verified pieces.
1854        if let Some(ct) = &self.chunk_tracker {
1855            let atomic_states = Arc::new(AtomicPieceStates::new(
1856                self.num_pieces,
1857                ct.bitfield(),
1858                &self.wanted_pieces,
1859            ));
1860            self.atomic_states = Some(Arc::clone(&atomic_states));
1861            self.availability = vec![0u32; self.num_pieces as usize];
1862            self.piece_owner = vec![None; self.num_pieces as usize];
1863            // M149: Initialize inflight tracking
1864            self.inflight_started = vec![None; self.num_pieces as usize];
1865            self.max_in_flight = self.config.max_in_flight_pieces;
1866
1867            // M103: Initialize block stealing infrastructure
1868            if self.config.use_block_stealing {
1869                if let Some(ref lengths) = self.lengths {
1870                    self.block_maps = Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
1871                }
1872                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
1873            }
1874            // M120: Per-piece write guards
1875            self.piece_write_guards = Some(Arc::new(
1876                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
1877            ));
1878
1879            let snapshot = Arc::new(AvailabilitySnapshot::build(
1880                &self.availability,
1881                &atomic_states,
1882                &self.priority_pieces,
1883                0,
1884            ));
1885            self.availability_snapshot = Some(snapshot);
1886            self.snapshot_generation = 0;
1887
1888            let notify = Arc::new(tokio::sync::Notify::new());
1889            self.reservation_notify = Some(notify);
1890        }
1891
1892        // Spawn web seeds if not already seeding
1893        if self.state != TorrentState::Seeding {
1894            self.spawn_web_seeds();
1895            self.assign_pieces_to_web_seeds();
1896        }
1897
1898        // M147: Set up ConnectPool — semaphore gates connection attempts only.
1899        // Permits are released on HandshakeComplete, not held for peer lifetime.
1900        let connect_semaphore = Arc::new(tokio::sync::Semaphore::new(
1901            self.effective_max_connections(),
1902        ));
1903        self.connect_semaphore = Arc::clone(&connect_semaphore);
1904        self.connect_permits.clear();
1905        // M137: Create PeerStates with the adder's input channel
1906        let (queue_tx, queue_rx) = mpsc::unbounded_channel();
1907        let peer_states = Arc::new(crate::peer_states::PeerStates::new(queue_tx));
1908        self.peer_states = Some(Arc::clone(&peer_states));
1909        let (adder_connect_tx, adder_connect_rx) = mpsc::channel(64);
1910        self.connect_rx = Some(adder_connect_rx);
1911        // M147: ConnectPool semaphore gates connection attempts (released on handshake)
1912        tokio::spawn(peer_adder::peer_adder_task(
1913            queue_rx,
1914            Arc::clone(&connect_semaphore),
1915            Arc::clone(&peer_states),
1916            Arc::clone(&self.ban_manager),
1917            Arc::clone(&self.ip_filter),
1918            adder_connect_tx,
1919        ));
1920
1921        let mut unchoke_interval = tokio::time::interval(Duration::from_secs(10));
1922        let mut optimistic_interval = tokio::time::interval(Duration::from_secs(30));
1923        let mut refill_interval = tokio::time::interval(Duration::from_millis(100));
1924        let mut dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::ZERO));
1925        let mut suggest_interval = if self.config.suggest_mode {
1926            Some(tokio::time::interval(Duration::from_secs(30)))
1927        } else {
1928            None
1929        };
1930        // M136: 1s steal-queue maintenance tick.
1931        let mut turnover_interval = tokio::time::interval(Duration::from_secs(1));
1932        let mut pipeline_tick_interval = tokio::time::interval(Duration::from_millis(1000));
1933        // M77: Skip missed ticks — safety-net notify should fire at most once/second
1934        pipeline_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1935        let mut end_game_tick_interval = tokio::time::interval(Duration::from_millis(200));
1936        end_game_tick_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1937        let mut diag_interval = tokio::time::interval(Duration::from_secs(5));
1938        // M108: 30s connection success rate summary for variance diagnosis.
1939        let mut conn_stats_interval = tokio::time::interval(Duration::from_secs(30));
1940        // M107: 5s metadata piece timeout — only meaningful in FetchingMetadata state
1941        let mut metadata_timeout_interval = tokio::time::interval(Duration::from_secs(5));
1942        // M103: 50ms debounce for reactive snapshot (was 500ms fixed interval)
1943        let mut snapshot_rebuild_interval = tokio::time::interval(Duration::from_millis(50));
1944        // M147: 1s soft reap interval — disconnects connecting peers without TCP SYN-ACK
1945        let mut soft_reap_interval = tokio::time::interval(Duration::from_secs(1));
1946        // M148: 2s proactive eviction — breaks the catch-22 where LivePool fills with
1947        // deadweight and no HandshakeComplete events arrive to trigger eviction.
1948        let mut eviction_interval = tokio::time::interval(Duration::from_secs(2));
1949        eviction_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1950
1951        // Don't fire immediately for the first tick
1952        unchoke_interval.tick().await;
1953        optimistic_interval.tick().await;
1954        refill_interval.tick().await;
1955        // Note: dht_requery_sleep uses Sleep (not Interval), no initial tick skip needed
1956        if let Some(ref mut si) = suggest_interval {
1957            si.tick().await; // skip initial tick
1958        }
1959        turnover_interval.tick().await;
1960        pipeline_tick_interval.tick().await;
1961        end_game_tick_interval.tick().await;
1962        diag_interval.tick().await;
1963        conn_stats_interval.tick().await;
1964        metadata_timeout_interval.tick().await;
1965        snapshot_rebuild_interval.tick().await;
1966        soft_reap_interval.tick().await;
1967        eviction_interval.tick().await;
1968
1969        // Initial tracker announce (Started event) — non-blocking, fires via select! arm
1970        // DHT announce (v4 + v6) — dual-swarm for hybrid torrents
1971        if self.state == TorrentState::Downloading && self.config.enable_dht {
1972            // Primary hash (v1 or best_v1)
1973            if let Some(ref dht) = self.dht
1974                && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
1975            {
1976                warn!("DHT v4 announce failed: {e}");
1977            }
1978            if let Some(ref dht6) = self.dht_v6
1979                && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
1980            {
1981                debug!("DHT v6 announce failed: {e}");
1982            }
1983            // Dual-swarm: also announce v2 hash (truncated) for hybrid torrents
1984            if self.info_hashes.is_hybrid()
1985                && let Some(v2) = self.info_hashes.v2
1986            {
1987                let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
1988                if v2_as_v1 != self.info_hash {
1989                    if let Some(ref dht) = self.dht
1990                        && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
1991                    {
1992                        debug!("DHT v4 dual-swarm announce failed: {e}");
1993                    }
1994                    if let Some(ref dht6) = self.dht_v6
1995                        && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
1996                    {
1997                        debug!("DHT v6 dual-swarm announce failed: {e}");
1998                    }
1999                }
2000            }
2001        }
2002
2003        // I2P accept loop: spawn a background task that feeds incoming I2P
2004        // connections back via a channel, so the select! arm can handle them.
2005        if self.config.enable_i2p
2006            && let Some(ref sam) = self.sam_session
2007        {
2008            let (tx, rx) = mpsc::channel(16);
2009            let sam = Arc::clone(sam);
2010            tokio::spawn(async move {
2011                loop {
2012                    match sam.accept().await {
2013                        Ok(stream) => {
2014                            if tx.send(stream).await.is_err() {
2015                                break; // torrent actor dropped
2016                            }
2017                        }
2018                        Err(e) => {
2019                            warn!("I2P accept error: {e}");
2020                            tokio::time::sleep(Duration::from_secs(5)).await;
2021                        }
2022                    }
2023                }
2024            });
2025            self.i2p_accept_rx = Some(rx);
2026        }
2027
2028        loop {
2029            tokio::select! {
2030                biased;
2031                // Events from peers — batch-drain to reduce select! overhead.
2032                // At 100 MB/s we get ~6K events/sec; processing one-by-one
2033                // means 6K select! iterations with waker re-registration.
2034                // biased; ensures this high-throughput arm is checked first.
2035                event = self.event_rx.recv() => {
2036                    if let Some(event) = event {
2037                        self.handle_peer_event(event).await;
2038                        // Drain up to 512 more ready events without re-entering select!
2039                        for _ in 0..512 {
2040                            match self.event_rx.try_recv() {
2041                                Ok(event) => self.handle_peer_event(event).await,
2042                                Err(_) => break,
2043                            }
2044                        }
2045                    }
2046                }
2047                // Async piece verification results
2048                Some(result) = self.verify_result_rx.recv() => {
2049                    self.pending_verify.remove(&result.piece);
2050                    // Guard: ignore stale/duplicate results for already-verified pieces
2051                    let dominated = self.chunk_tracker.as_ref()
2052                        .map(|ct| ct.bitfield().get(result.piece))
2053                        .unwrap_or(false);
2054                    if !dominated {
2055                        if result.passed {
2056                            self.on_piece_verified(result.piece).await;
2057                        } else {
2058                            self.on_piece_hash_failed(result.piece).await;
2059                            // M73: Drivers pick up released pieces automatically via shared state
2060                        }
2061                    }
2062                }
2063                // M96: Hash pool verification results
2064                Some(result) = self.hash_result_rx.recv() => {
2065                    self.handle_hash_result(result).await;
2066                }
2067                // Commands from handle
2068                cmd = self.cmd_rx.recv() => {
2069                    match cmd {
2070                        Some(TorrentCommand::AddPeers { peers, source }) => {
2071                            self.handle_add_peers(peers, source);
2072                        }
2073                        Some(TorrentCommand::Stats { reply }) => {
2074                            let _ = reply.send(self.make_stats());
2075                        }
2076                        Some(TorrentCommand::Pause) => {
2077                            self.handle_pause().await;
2078                        }
2079                        Some(TorrentCommand::Resume) => {
2080                            self.handle_resume().await;
2081                        }
2082                        Some(TorrentCommand::SaveResumeData { reply }) => {
2083                            let result = self.build_resume_data();
2084                            let _ = reply.send(result);
2085                        }
2086                        Some(TorrentCommand::SetFilePriority { index, priority, reply }) => {
2087                            let result = self.handle_set_file_priority(index, priority);
2088                            let _ = reply.send(result);
2089                        }
2090                        Some(TorrentCommand::FilePriorities { reply }) => {
2091                            let _ = reply.send(self.file_priorities.clone());
2092                        }
2093                        Some(TorrentCommand::ForceReannounce) => {
2094                            self.tracker_manager.force_reannounce();
2095                        }
2096                        Some(TorrentCommand::TrackerList { reply }) => {
2097                            let _ = reply.send(self.tracker_manager.tracker_list());
2098                        }
2099                        Some(TorrentCommand::Scrape { reply }) => {
2100                            let result = self.tracker_manager.scrape().await;
2101                            if let Some((ref url, ref info)) = result {
2102                                post_alert(&self.alert_tx, &self.alert_mask, AlertKind::ScrapeReply {
2103                                    info_hash: self.info_hash,
2104                                    url: url.clone(),
2105                                    complete: info.complete,
2106                                    incomplete: info.incomplete,
2107                                    downloaded: info.downloaded,
2108                                });
2109                            }
2110                            let _ = reply.send(result);
2111                        }
2112                        Some(TorrentCommand::OpenFile { file_index, reply }) => {
2113                            let result = self.handle_open_file(file_index);
2114                            let _ = reply.send(result);
2115                        }
2116                        Some(TorrentCommand::IncomingPeer { stream, addr }) => {
2117                            self.spawn_peer_from_stream_with_mode(
2118                                addr,
2119                                stream,
2120                                Some(irontide_wire::mse::EncryptionMode::Disabled),
2121                            );
2122                        }
2123                        Some(TorrentCommand::UpdateExternalIp { ip }) => {
2124                            self.external_ip = Some(ip);
2125                            post_alert(
2126                                &self.alert_tx,
2127                                &self.alert_mask,
2128                                AlertKind::ExternalIpDetected { ip },
2129                            );
2130                        }
2131                        Some(TorrentCommand::MoveStorage { new_path, reply }) => {
2132                            let result = self.handle_move_storage(new_path).await;
2133                            let _ = reply.send(result);
2134                        }
2135                        Some(TorrentCommand::SpawnSslPeer { addr, stream }) => {
2136                            // TLS is already completed; encryption is handled by TLS layer
2137                            self.spawn_peer_from_stream_with_mode(
2138                                addr,
2139                                stream.0,
2140                                Some(irontide_wire::mse::EncryptionMode::Disabled),
2141                            );
2142                        }
2143                        Some(TorrentCommand::SetDownloadLimit { bytes_per_sec, reply }) => {
2144                            self.download_bucket.set_rate(bytes_per_sec);
2145                            let _ = reply.send(());
2146                        }
2147                        Some(TorrentCommand::SetUploadLimit { bytes_per_sec, reply }) => {
2148                            self.upload_bucket.set_rate(bytes_per_sec);
2149                            let _ = reply.send(());
2150                        }
2151                        Some(TorrentCommand::DownloadLimit { reply }) => {
2152                            let _ = reply.send(self.download_bucket.rate());
2153                        }
2154                        Some(TorrentCommand::UploadLimit { reply }) => {
2155                            let _ = reply.send(self.upload_bucket.rate());
2156                        }
2157                        Some(TorrentCommand::SetSequentialDownload { enabled, reply }) => {
2158                            self.config.sequential_download = enabled;
2159                            let _ = reply.send(());
2160                        }
2161                        Some(TorrentCommand::IsSequentialDownload { reply }) => {
2162                            let _ = reply.send(self.config.sequential_download);
2163                        }
2164                        Some(TorrentCommand::SetSuperSeeding { enabled, reply }) => {
2165                            self.config.super_seeding = enabled;
2166                            self.super_seed = if enabled {
2167                                Some(crate::super_seed::SuperSeedState::new())
2168                            } else {
2169                                None
2170                            };
2171                            let _ = reply.send(());
2172                        }
2173                        Some(TorrentCommand::IsSuperSeeding { reply }) => {
2174                            let _ = reply.send(self.config.super_seeding);
2175                        }
2176                        Some(TorrentCommand::SetSeedMode { enabled, reply }) => {
2177                            self.handle_set_seed_mode(enabled);
2178                            let _ = reply.send(());
2179                        }
2180                        Some(TorrentCommand::AddTracker { url }) => {
2181                            self.tracker_manager.add_tracker_url(&url);
2182                        }
2183                        Some(TorrentCommand::ReplaceTrackers { urls, reply }) => {
2184                            self.tracker_manager.replace_all(urls);
2185                            let _ = reply.send(());
2186                        }
2187                        Some(TorrentCommand::ForceRecheck { reply }) => {
2188                            self.handle_force_recheck(reply).await;
2189                        }
2190                        Some(TorrentCommand::RenameFile { file_index, new_name, reply }) => {
2191                            let result = self.handle_rename_file(file_index, new_name).await;
2192                            let _ = reply.send(result);
2193                        }
2194                        Some(TorrentCommand::SetMaxConnections { limit, reply }) => {
2195                            self.max_connections = limit;
2196                            let _ = reply.send(());
2197                        }
2198                        Some(TorrentCommand::MaxConnections { reply }) => {
2199                            let _ = reply.send(self.max_connections);
2200                        }
2201                        Some(TorrentCommand::SetMaxUploads { limit, reply }) => {
2202                            self.choker.set_unchoke_slots(limit);
2203                            let _ = reply.send(());
2204                        }
2205                        Some(TorrentCommand::MaxUploads { reply }) => {
2206                            let _ = reply.send(self.choker.unchoke_slots());
2207                        }
2208                        Some(TorrentCommand::GetPeerInfo { reply }) => {
2209                            let _ = reply.send(self.build_peer_info());
2210                        }
2211                        Some(TorrentCommand::GetDownloadQueue { reply }) => {
2212                            let _ = reply.send(self.build_download_queue());
2213                        }
2214                        Some(TorrentCommand::HavePiece { index, reply }) => {
2215                            let has = self.chunk_tracker.as_ref()
2216                                .map(|ct| ct.has_piece(index))
2217                                .unwrap_or(false);
2218                            let _ = reply.send(has);
2219                        }
2220                        Some(TorrentCommand::PieceAvailability { reply }) => {
2221                            let avail = self.availability.clone();
2222                            let _ = reply.send(avail);
2223                        }
2224                        Some(TorrentCommand::FileProgress { reply }) => {
2225                            let _ = reply.send(self.compute_file_progress());
2226                        }
2227                        Some(TorrentCommand::InfoHashes { reply }) => {
2228                            let _ = reply.send(self.info_hashes.clone());
2229                        }
2230                        Some(TorrentCommand::TorrentFile { reply }) => {
2231                            let _ = reply.send(self.meta.clone());
2232                        }
2233                        Some(TorrentCommand::TorrentFileV2 { reply }) => {
2234                            let _ = reply.send(self.meta_v2.clone());
2235                        }
2236                        Some(TorrentCommand::ForceDhtAnnounce) => {
2237                            self.handle_force_dht_announce().await;
2238                        }
2239                        Some(TorrentCommand::ReadPiece { index, reply }) => {
2240                            let result = self.handle_read_piece(index).await;
2241                            let _ = reply.send(result);
2242                        }
2243                        Some(TorrentCommand::FlushCache { reply }) => {
2244                            let result = self.handle_flush_cache().await;
2245                            let _ = reply.send(result);
2246                        }
2247                        Some(TorrentCommand::ClearError) => {
2248                            self.handle_clear_error().await;
2249                        }
2250                        Some(TorrentCommand::ClearSaveResumeFlag) => {
2251                            self.need_save_resume = false;
2252                        }
2253                        Some(TorrentCommand::RestoreResumeBitmap { pieces, reply }) => {
2254                            let result = self.handle_restore_resume_bitmap(pieces);
2255                            let _ = reply.send(result);
2256                        }
2257                        Some(TorrentCommand::FileStatus { reply }) => {
2258                            let _ = reply.send(self.build_file_status());
2259                        }
2260                        Some(TorrentCommand::Flags { reply }) => {
2261                            let _ = reply.send(self.build_flags());
2262                        }
2263                        Some(TorrentCommand::SetFlags { flags, reply }) => {
2264                            self.apply_set_flags(flags).await;
2265                            let _ = reply.send(());
2266                        }
2267                        Some(TorrentCommand::UnsetFlags { flags, reply }) => {
2268                            self.apply_unset_flags(flags).await;
2269                            let _ = reply.send(());
2270                        }
2271                        Some(TorrentCommand::ConnectPeer { addr }) => {
2272                            self.handle_connect_peer(addr);
2273                        }
2274                        Some(TorrentCommand::PreResolvedMetadata { info_bytes, peers }) => {
2275                            self.handle_pre_resolved_metadata(info_bytes, peers).await;
2276                        }
2277                        Some(TorrentCommand::Shutdown) => {
2278                            info!("torrent actor: received Shutdown command, exiting");
2279                            self.shutdown_web_seeds().await;
2280                            self.shutdown_peers().await;
2281                            return;
2282                        }
2283                        None => {
2284                            warn!("torrent actor: cmd_rx channel closed (all senders dropped), exiting");
2285                            self.shutdown_web_seeds().await;
2286                            self.shutdown_peers().await;
2287                            return;
2288                        }
2289                    }
2290                }
2291                // Async disk write errors
2292                Some(err) = self.write_error_rx.recv() => {
2293                    warn!(piece = err.piece, begin = err.begin, "async disk write failed: {}", err.error);
2294                }
2295                // Accept incoming peers
2296                result = accept_incoming(&mut self.listener) => {
2297                    if let Ok((stream, addr)) = result {
2298                        self.spawn_peer_from_stream(addr, stream);
2299                    }
2300                }
2301                // Accept incoming I2P peers (M41)
2302                stream = accept_i2p(&mut self.i2p_accept_rx) => {
2303                    if let Some(stream) = stream {
2304                        self.handle_i2p_incoming(stream);
2305                    }
2306                }
2307                // Unchoke timer
2308                _ = unchoke_interval.tick() => {
2309                    self.update_peer_rates();
2310                    // M144: Skip choker during download — match rqbit's model.
2311                    // We send Unchoke unconditionally on connect (M107). Running
2312                    // the choker every 10s re-chokes most peers, killing tit-for-tat
2313                    // reciprocity (remote peers only unchoke us if we unchoke them).
2314                    // rqbit permanently unchokes all peers during download.
2315                    // Only run the choker when seeding (upload-side slot management).
2316                    if self.state == TorrentState::Seeding
2317                        || self.state == TorrentState::Sharing
2318                    {
2319                        self.slot_tuner.observe(self.upload_bytes_interval);
2320                        self.choker.observe_throughput(self.upload_bytes_interval);
2321                        self.upload_bytes_interval = 0;
2322                        self.choker.set_unchoke_slots(self.slot_tuner.current_slots());
2323                        self.run_choker().await;
2324                    } else {
2325                        self.upload_bytes_interval = 0;
2326                    }
2327                    // Update streaming cursors and piece priorities
2328                    self.update_streaming_cursors();
2329                    // Update auto-sequential hysteresis (M45)
2330                    if self.config.auto_sequential {
2331                        self.auto_sequential_active = crate::piece_selector::evaluate_auto_sequential(
2332                            self.piece_owner.iter().filter(|o| o.is_some()).count(),
2333                            self.peers.len(),
2334                            self.auto_sequential_active,
2335                        );
2336                    }
2337                    // Periodic web seed piece reassignment (moved from dht_recheck timer)
2338                    self.assign_pieces_to_web_seeds();
2339                }
2340                // Optimistic unchoke timer
2341                _ = optimistic_interval.tick() => {
2342                    self.rotate_optimistic();
2343                }
2344                // M107: Receive connect requests from the peer adder task
2345                Some(connect_peer) = async {
2346                    match self.connect_rx.as_mut() {
2347                        Some(rx) => rx.recv().await,
2348                        None => std::future::pending().await,
2349                    }
2350                } => {
2351                    self.handle_adder_connect(connect_peer);
2352                }
2353                () = &mut dht_requery_sleep, if self.state != TorrentState::Complete
2354                    && self.state != TorrentState::Paused
2355                    && self.state != TorrentState::Seeding
2356                    && self.state != TorrentState::Stopped => {
2357                    self.run_dht_requery().await;
2358                    dht_requery_sleep = Box::pin(tokio::time::sleep(Duration::from_secs(60)));
2359                }
2360                // M143: Tracker re-announce timer — starts a background announce,
2361                // never blocks. Only fires when no announce is in-flight.
2362                // Also fires during FetchingMetadata so magnets with &tr= URLs
2363                // can discover peers before metadata arrives.
2364                _ = async {
2365                    match self.tracker_manager.next_announce_in() {
2366                        Some(dur) => tokio::time::sleep(dur).await,
2367                        None => std::future::pending().await,
2368                    }
2369                }, if self.tracker_result_rx.is_none() => {
2370                    let left = self.calculate_left();
2371                    self.tracker_result_rx = Some(self.tracker_manager.start_announce(
2372                        irontide_tracker::AnnounceEvent::None,
2373                        self.uploaded,
2374                        self.downloaded,
2375                        left,
2376                    ));
2377                }
2378                // M143: Streaming tracker results — process each tracker
2379                // response as it arrives, without blocking the actor loop.
2380                result = async {
2381                    match self.tracker_result_rx.as_mut() {
2382                        Some(rx) => rx.recv().await,
2383                        None => std::future::pending().await,
2384                    }
2385                } => {
2386                    match result {
2387                        Some(batch) => {
2388                            let (peers, outcome) = self.tracker_manager.process_tracker_result(batch);
2389                            self.fire_tracker_alerts(&[outcome]);
2390                            if !peers.is_empty() {
2391                                debug!(count = peers.len(), "tracker returned peers (streaming)");
2392                                self.handle_add_peers(peers, PeerSource::Tracker);
2393                            }
2394                        }
2395                        None => {
2396                            // All trackers responded — clear in-flight state so
2397                            // the timer arm can re-fire for the next announce cycle.
2398                            self.tracker_result_rx = None;
2399                        }
2400                    }
2401                }
2402                // DHT v4 peer discovery
2403                result = async {
2404                    match &mut self.dht_peers_rx {
2405                        Some(rx) => rx.recv().await,
2406                        None => std::future::pending().await,
2407                    }
2408                } => {
2409                    match result {
2410                        Some(peers) => {
2411                            debug!(count = peers.len(), "DHT v4 returned peers");
2412                            self.handle_add_peers(peers, PeerSource::Dht);
2413                        }
2414                        None => {
2415                            debug!("DHT v4 peer search exhausted");
2416                            self.dht_peers_rx = None;
2417                        }
2418                    }
2419                }
2420                // DHT v6 peer discovery
2421                result = async {
2422                    match &mut self.dht_v6_peers_rx {
2423                        Some(rx) => rx.recv().await,
2424                        None => std::future::pending().await,
2425                    }
2426                } => {
2427                    match result {
2428                        Some(peers) => {
2429                            debug!(count = peers.len(), "DHT v6 returned peers");
2430                            self.dht_v6_empty_count = 0; // V6 is working, reset
2431                            self.handle_add_peers(peers, PeerSource::Dht);
2432                        }
2433                        None => {
2434                            self.dht_v6_peers_rx = None;
2435                            self.dht_v6_empty_count += 1;
2436                            if self.dht_v6_empty_count == 30 {
2437                                debug!("DHT v6 routing table persistently empty, giving up");
2438                            } else if self.dht_v6_empty_count < 30 {
2439                                debug!("DHT v6 peer search exhausted");
2440                            }
2441                        }
2442                    }
2443                }
2444                // Dual-swarm: DHT v4 v2-hash peer discovery (hybrid)
2445                result = async {
2446                    match &mut self.dht_v2_peers_rx {
2447                        Some(rx) => rx.recv().await,
2448                        None => std::future::pending().await,
2449                    }
2450                } => {
2451                    match result {
2452                        Some(peers) => {
2453                            debug!(count = peers.len(), "DHT v4 v2-swarm returned peers");
2454                            self.handle_add_peers(peers, PeerSource::Dht);
2455                        }
2456                        None => {
2457                            debug!("DHT v4 v2-swarm peer search exhausted");
2458                            self.dht_v2_peers_rx = None;
2459                        }
2460                    }
2461                }
2462                // Dual-swarm: DHT v6 v2-hash peer discovery (hybrid)
2463                result = async {
2464                    match &mut self.dht_v6_v2_peers_rx {
2465                        Some(rx) => rx.recv().await,
2466                        None => std::future::pending().await,
2467                    }
2468                } => {
2469                    match result {
2470                        Some(peers) => {
2471                            debug!(count = peers.len(), "DHT v6 v2-swarm returned peers");
2472                            self.handle_add_peers(peers, PeerSource::Dht);
2473                        }
2474                        None => {
2475                            debug!("DHT v6 v2-swarm peer search exhausted");
2476                            self.dht_v6_v2_peers_rx = None;
2477                        }
2478                    }
2479                }
2480                // M44: Suggest cached pieces timer
2481                _ = async {
2482                    match suggest_interval {
2483                        Some(ref mut interval) => interval.tick().await,
2484                        None => std::future::pending().await,
2485                    }
2486                } => {
2487                    self.suggest_cached_pieces().await;
2488                }
2489                _ = turnover_interval.tick() => {
2490                    self.run_steal_queue_maintenance();
2491                }
2492                // Pipeline tick (1s) — update EWMA, snub detection, peer scoring
2493                _ = pipeline_tick_interval.tick() => {
2494                    let snub_timeout = Duration::from_secs(self.config.snub_timeout_secs as u64);
2495
2496                    for (_addr, peer) in self.peers.iter_mut() {
2497                        peer.pipeline.tick();
2498
2499                        // Snub detection: no data for snub_timeout_secs while unchoked
2500                        if !peer.peer_choking && !peer.snubbed {
2501                            let idle = peer.last_data_received
2502                                .map(|t| t.elapsed() > snub_timeout)
2503                                .unwrap_or(false);
2504                            if idle {
2505                                peer.snubbed = true;
2506                                // M106: Count pending requests as timed-out blocks
2507                                peer.blocks_timed_out = peer.blocks_timed_out
2508                                    .saturating_add(peer.pending_requests.len() as u64);
2509                                debug!(%_addr, "peer snubbed (no data for {}s)", self.config.snub_timeout_secs);
2510                            }
2511                        }
2512                    }
2513
2514                    // Refresh cached peer rates for steal decisions (avoids
2515                    // rebuilding a FxHashMap from all peers on every block arrival).
2516                    self.refresh_peer_rates();
2517
2518                    // M73: Periodic endgame activation check (was in batch_fill_all_peers)
2519                    if !self.end_game.is_active() {
2520                        self.check_end_game_activation();
2521                    }
2522
2523                    // M77: Safety-net periodic notification — catches availability
2524                    // changes from add_peer()/peer_have() that no longer notify immediately.
2525                    if let Some(ref notify) = self.reservation_notify {
2526                        notify.notify_waiters();
2527                    }
2528
2529                    // M138: Proactive choke rotation — every tick, evict up to N choked peers
2530                    if self.config.choke_rotation_max_evictions > 0
2531                        && self.state == TorrentState::Downloading
2532                    {
2533                        self.run_choke_rotation();
2534                    }
2535                }
2536                // (M75: peer tasks handle dispatch via integrated select! arm)
2537                // End-game refill tick (200ms) — replace reactive per-block cascade
2538                // with periodic batch refill. All peers with available pipeline slots
2539                // get new end-game blocks, preventing idle stalls between ticks.
2540                _ = end_game_tick_interval.tick(), if self.end_game.is_active() => {
2541                    let addrs: Vec<SocketAddr> = self.peers.iter()
2542                        .filter(|(_, p)| !p.peer_choking && p.pending_requests.len() < END_GAME_DEPTH)
2543                        .map(|(addr, _)| *addr)
2544                        .collect();
2545                    for addr in addrs {
2546                        self.request_end_game_block(addr).await;
2547                    }
2548                }
2549                // M107: Metadata piece timeout — re-request timed-out pieces from
2550                // all non-rejected peers that support ut_metadata.
2551                _ = metadata_timeout_interval.tick(), if self.state == TorrentState::FetchingMetadata => {
2552                    // Collect timed-out pieces (immutable borrow, then release).
2553                    let timed_out: Vec<u32> = self
2554                        .metadata_downloader
2555                        .as_ref()
2556                        .map(|dl| dl.timed_out_pieces(Duration::from_secs(5)))
2557                        .unwrap_or_default();
2558
2559                    if !timed_out.is_empty() {
2560                        debug!(count = timed_out.len(), "metadata pieces timed out, re-requesting");
2561
2562                        // Collect eligible peers (non-rejected, support ut_metadata).
2563                        // Clone cmd_tx to avoid holding borrows across the send loop.
2564                        let eligible_senders: Vec<mpsc::Sender<PeerCommand>> = self
2565                            .peers
2566                            .iter()
2567                            .filter(|(addr, peer)| {
2568                                self.metadata_downloader
2569                                    .as_ref()
2570                                    .is_some_and(|dl| !dl.is_rejected(addr))
2571                                    && peer
2572                                        .ext_handshake
2573                                        .as_ref()
2574                                        .is_some_and(|h| h.metadata_size.is_some())
2575                            })
2576                            .map(|(_, peer)| peer.cmd_tx.clone())
2577                            .collect();
2578
2579                        // Send requests (uses cloned senders, no borrow conflict).
2580                        for cmd_tx in &eligible_senders {
2581                            for &piece in &timed_out {
2582                                let _ = cmd_tx.try_send(PeerCommand::RequestMetadata { piece });
2583                            }
2584                        }
2585
2586                        // Update request times in the downloader.
2587                        if let Some(ref mut dl) = self.metadata_downloader {
2588                            for piece in timed_out {
2589                                dl.reset_request_time(piece);
2590                            }
2591                        }
2592                    }
2593                }
2594                // Periodic download status report (5s)
2595                _ = diag_interval.tick() => {
2596                    // Heartbeat: log state regardless of download state
2597                    {
2598                        let have = self.chunk_tracker.as_ref().map(|ct| ct.bitfield().count_ones()).unwrap_or(0);
2599                        let eg = self.end_game.is_active();
2600                        let eg_blocks = self.end_game.block_count();
2601                        info!(state = ?self.state, have, total = self.num_pieces, end_game = eg, eg_blocks, "heartbeat");
2602                    }
2603                    if self.state == TorrentState::Downloading {
2604                        let have = self.chunk_tracker.as_ref().map(|ct| ct.bitfield().count_ones()).unwrap_or(0);
2605                        let in_flight = self.atomic_states.as_ref().map(|s| s.in_flight_count() as usize).unwrap_or(0);
2606                        let unchoked = self.peers.values().filter(|p| !p.peer_choking).count();
2607                        info!(have, in_flight, total = self.num_pieces,
2608                              downloaded_mb = self.downloaded / (1024 * 1024),
2609                              peers = self.peers.len(), unchoked,
2610                              "download progress");
2611                        for (addr, p) in &self.peers {
2612                            let last_data = p.last_data_received.map(|t| t.elapsed().as_secs()).unwrap_or(9999);
2613                            trace!(%addr,
2614                                   choking = p.peer_choking,
2615                                   pending = p.pending_requests.len(),
2616                                   ewma_rate = p.pipeline.ewma_rate() as u64,
2617                                   last_data_secs = last_data,
2618                                   bf_ones = p.bitfield.count_ones(),
2619                                   "peer state");
2620                        }
2621                    }
2622                }
2623                // M108: 30s connection success rate summary for variance diagnosis
2624                _ = conn_stats_interval.tick() => {
2625                    if self.connect_attempts > 0 {
2626                        let succeeded = self.connect_attempts.saturating_sub(self.connect_failures);
2627                        let success_pct = (succeeded as f64 / self.connect_attempts as f64 * 100.0) as u32;
2628                        info!(
2629                            connected = self.peers.len(),
2630                            attempted = self.connect_attempts,
2631                            failed = self.connect_failures,
2632                            success_rate = %format!("{success_pct}%"),
2633                            "connection stats"
2634                        );
2635                    }
2636                }
2637                // M103: Reactive snapshot rebuild with 50ms debounce (was 500ms fixed)
2638                _ = snapshot_rebuild_interval.tick() => {
2639                    if self.snapshot_dirty {
2640                        self.snapshot_dirty = false;
2641                        self.rebuild_availability_snapshot();
2642                    }
2643                }
2644                // M147: Soft reap — disconnect connecting peers without TCP SYN-ACK
2645                _ = soft_reap_interval.tick() => {
2646                    let soft_timeout = self.config.connect_soft_timeout;
2647                    if soft_timeout > 0 {
2648                        // Collect candidates first, then mutate self.
2649                        let candidates = self.peer_states.as_ref().map_or_else(
2650                            Vec::new,
2651                            |ps| ps.soft_reap_candidates(Duration::from_secs(soft_timeout)),
2652                        );
2653                        for peer_addr in candidates {
2654                            debug!(%peer_addr, soft_timeout, "soft reap: no TCP SYN-ACK");
2655                            // Remove from connect_permits so RAII drops the permit
2656                            self.connect_permits.remove(&peer_addr);
2657                            self.disconnect_peer(peer_addr, "soft reap: no TCP SYN-ACK");
2658                            if let Some(ref ps) = self.peer_states
2659                                && let Some(backoff) = ps.mark_dead(peer_addr)
2660                            {
2661                                let ps_clone = Arc::clone(ps);
2662                                tokio::spawn(async move {
2663                                    tokio::time::sleep(backoff).await;
2664                                    ps_clone.mark_queued_for_retry(peer_addr);
2665                                });
2666                            }
2667                        }
2668                    }
2669                }
2670                // M148: Proactive eviction — break the deadweight catch-22
2671                _ = eviction_interval.tick() => {
2672                    if self.state != TorrentState::Seeding {
2673                        // Pressure gate: only evict when pool is >=75% full
2674                        let should_evict = if let Some(ref ps) = self.peer_states {
2675                            let live = ps.stats.live.load(std::sync::atomic::Ordering::Relaxed);
2676                            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2677                            let threshold = (self.effective_max_connections() as f32 * 0.75) as u32;
2678                            live >= threshold
2679                        } else {
2680                            false
2681                        };
2682                        if should_evict {
2683                            // Evict up to 5 deadweight per tick
2684                            for _ in 0..5 {
2685                                match self.find_eviction_candidate() {
2686                                    Some((victim, pass)) => {
2687                                        debug!(%victim, ?pass, "M148 proactive eviction");
2688                                        self.disconnect_peer(victim, "proactive eviction");
2689                                        if matches!(pass, crate::torrent_peers::EvictionPass::ZeroThroughput)
2690                                            && let Some(ref ps) = self.peer_states
2691                                        {
2692                                            ps.add_eviction_ban(victim);
2693                                        }
2694                                    }
2695                                    None => break,
2696                                }
2697                            }
2698                        }
2699
2700                        // M149: Piece stealing scan (piggybacks on same 2s interval)
2701                        self.run_piece_steal_scan();
2702                    }
2703                }
2704                // Rate limiter refill (100ms)
2705                _ = refill_interval.tick() => {
2706                    let elapsed = Duration::from_millis(100);
2707                    self.upload_bucket.refill(elapsed);
2708                    self.download_bucket.refill(elapsed);
2709                    // Refill per-class buckets and apply mixed-mode (M45)
2710                    self.rate_limiter_set.refill(elapsed);
2711                    let (tcp_peers, utp_peers) = self.transport_peer_counts();
2712                    self.rate_limiter_set.apply_mixed_mode(
2713                        self.config.mixed_mode_algorithm,
2714                        tcp_peers,
2715                        utp_peers,
2716                        self.config.upload_rate_limit,
2717                    );
2718                }
2719            }
2720
2721            // M112: drain holepunch attempts (bridging sync disconnect_peer → async try_holepunch)
2722            for target in std::mem::take(&mut self.holepunch_pending) {
2723                self.try_holepunch(target).await;
2724            }
2725        }
2726    }
2727
2728    // ----- Command handlers -----
2729
2730    /// Compute distributed copy availability across the swarm.
2731    ///
2732    /// Returns `(full_copies, fraction, copies_float)` where `fraction` is in thousandths.
2733    pub(crate) fn distributed_copies(&self) -> (u32, u32, f32) {
2734        if self.num_pieces == 0 || self.peers.is_empty() {
2735            return (0, 0, 0.0);
2736        }
2737
2738        let num = self.num_pieces as usize;
2739        let mut availability = vec![0u32; num];
2740
2741        for peer in self.peers.values() {
2742            for idx in 0..self.num_pieces {
2743                if peer.bitfield.get(idx) {
2744                    availability[idx as usize] += 1;
2745                }
2746            }
2747        }
2748
2749        let min_avail = availability.iter().copied().min().unwrap_or(0);
2750        let rarest_count = availability.iter().filter(|&&c| c == min_avail).count() as u32;
2751        let fraction = ((self.num_pieces - rarest_count) * 1000) / self.num_pieces;
2752        let copies_float = min_avail as f32 + fraction as f32 / 1000.0;
2753
2754        (min_avail, fraction, copies_float)
2755    }
2756
2757    fn build_download_queue(&self) -> Vec<PartialPieceInfo> {
2758        self.piece_owner
2759            .iter()
2760            .enumerate()
2761            .filter_map(|(piece_index, owner)| {
2762                owner.map(|_| {
2763                    let piece_index = piece_index as u32;
2764                    let blocks_in_piece = self
2765                        .lengths
2766                        .as_ref()
2767                        .map(|l| l.piece_size(piece_index).div_ceil(l.chunk_size()))
2768                        .unwrap_or(0);
2769                    PartialPieceInfo {
2770                        piece_index,
2771                        blocks_in_piece,
2772                        blocks_assigned: 0,
2773                    }
2774                })
2775            })
2776            .collect()
2777    }
2778
2779    /// Compute per-file downloaded bytes.
2780    fn compute_file_progress(&self) -> Vec<u64> {
2781        let meta = match self.meta.as_ref() {
2782            Some(m) => m,
2783            None => return Vec::new(),
2784        };
2785        let lengths = match self.lengths.as_ref() {
2786            Some(l) => l,
2787            None => return Vec::new(),
2788        };
2789        let chunk_tracker = match self.chunk_tracker.as_ref() {
2790            Some(ct) => ct,
2791            None => return Vec::new(),
2792        };
2793
2794        let files = meta.info.files();
2795        if files.is_empty() {
2796            return Vec::new();
2797        }
2798
2799        let piece_length = lengths.piece_length();
2800        let mut result = Vec::with_capacity(files.len());
2801        let mut file_offset = 0u64;
2802
2803        for file_entry in &files {
2804            let file_len = file_entry.length;
2805            if file_len == 0 {
2806                result.push(0);
2807                file_offset += file_len;
2808                continue;
2809            }
2810
2811            let file_end = file_offset + file_len;
2812            let first_piece = (file_offset / piece_length) as u32;
2813            let last_piece = ((file_end - 1) / piece_length) as u32;
2814
2815            let mut downloaded = 0u64;
2816
2817            for p in first_piece..=last_piece {
2818                if !chunk_tracker.has_piece(p) {
2819                    continue;
2820                }
2821
2822                let piece_start = lengths.piece_offset(p);
2823                let piece_end = piece_start + lengths.piece_size(p) as u64;
2824
2825                // Clamp to file boundaries
2826                let overlap_start = piece_start.max(file_offset);
2827                let overlap_end = piece_end.min(file_end);
2828
2829                if overlap_start < overlap_end {
2830                    downloaded += overlap_end - overlap_start;
2831                }
2832            }
2833
2834            result.push(downloaded);
2835            file_offset = file_end;
2836        }
2837
2838        result
2839    }
2840
2841    /// Exponential backoff delay for V6 DHT retries (M97).
2842    /// 100ms, 200ms, 400ms, 800ms, 1600ms, 3200ms, 5000ms (cap).
2843    fn v6_retry_delay(&self) -> std::time::Duration {
2844        let base_ms: u64 = 100;
2845        let max_ms: u64 = 5000;
2846        let delay_ms = base_ms
2847            .saturating_mul(
2848                1u64.checked_shl(self.dht_v6_empty_count)
2849                    .unwrap_or(u64::MAX),
2850            )
2851            .min(max_ms);
2852        std::time::Duration::from_millis(delay_ms)
2853    }
2854
2855    /// Check if enough time has elapsed for the next V6 DHT retry (M97).
2856    fn should_retry_v6(&self) -> bool {
2857        let Some(last) = self.dht_v6_last_retry else {
2858            return true; // First attempt
2859        };
2860        last.elapsed() >= self.v6_retry_delay()
2861    }
2862
2863    /// Force an immediate DHT announce on all available DHT handles (v4 + v6).
2864    async fn handle_force_dht_announce(&self) {
2865        if let Some(ref dht) = self.dht
2866            && let Err(e) = dht.announce(self.info_hash, self.config.listen_port).await
2867        {
2868            warn!("Force DHT v4 announce failed: {e}");
2869        }
2870        if let Some(ref dht6) = self.dht_v6
2871            && let Err(e) = dht6.announce(self.info_hash, self.config.listen_port).await
2872        {
2873            debug!("Force DHT v6 announce failed: {e}");
2874        }
2875        // Dual-swarm: also announce v2 hash for hybrid torrents
2876        if self.info_hashes.is_hybrid()
2877            && let Some(v2) = self.info_hashes.v2
2878        {
2879            let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
2880            if v2_as_v1 != self.info_hash {
2881                if let Some(ref dht) = self.dht
2882                    && let Err(e) = dht.announce(v2_as_v1, self.config.listen_port).await
2883                {
2884                    debug!("Force DHT v4 dual-swarm announce failed: {e}");
2885                }
2886                if let Some(ref dht6) = self.dht_v6
2887                    && let Err(e) = dht6.announce(v2_as_v1, self.config.listen_port).await
2888                {
2889                    debug!("Force DHT v6 dual-swarm announce failed: {e}");
2890                }
2891            }
2892        }
2893    }
2894
2895    /// M107: Periodic DHT re-query — discovers new peers during download.
2896    ///
2897    /// Replaces the old fixed 30s `dht_recheck_interval`. Clears the adder's
2898    /// seen set so previously-known peers can be re-evaluated, then issues
2899    /// fresh `get_peers` on all active DHT handles (v4, v6, v2-swarm).
2900    async fn run_dht_requery(&mut self) {
2901        if !self.config.enable_dht {
2902            return;
2903        }
2904
2905        // Guard: don't re-query if we already have plenty of known peers.
2906        // M133: Scale with config instead of hardcoded 500 — with max_peers=128
2907        // this becomes 512, close to the old value but adapts to custom limits.
2908        if self.peers.len() > self.config.max_peers.saturating_mul(4) {
2909            return;
2910        }
2911
2912        // M134: DhtLookup is now persistent — it re-injects routing table roots
2913        // every 15s internally. Only issue a fresh get_peers if the previous
2914        // lookup's channel has closed (lookup exhausted or aborted). Issuing a
2915        // new get_peers while one is active would abort the existing DhtLookup,
2916        // destroying its accumulated 256-node state.
2917
2918        // v4 DHT — only start if no active lookup
2919        if self.dht_peers_rx.is_none()
2920            && let Some(ref dht) = self.dht
2921        {
2922            match dht.get_peers(self.info_hash).await {
2923                Ok(rx) => self.dht_peers_rx = Some(rx),
2924                Err(e) => warn!("DHT v4 re-query failed: {e}"),
2925            }
2926        }
2927
2928        // v6 DHT — only start if no active lookup
2929        if self.dht_v6_peers_rx.is_none()
2930            && self.dht_v6_empty_count < 30
2931            && self.should_retry_v6()
2932            && let Some(ref dht6) = self.dht_v6
2933        {
2934            self.dht_v6_last_retry = Some(std::time::Instant::now());
2935            match dht6.get_peers(self.info_hash).await {
2936                Ok(rx) => self.dht_v6_peers_rx = Some(rx),
2937                Err(e) => debug!("DHT v6 re-query failed: {e}"),
2938            }
2939        }
2940
2941        // v2 swarm re-query for hybrid torrents — only start if no active lookup
2942        if self.info_hashes.is_hybrid()
2943            && let Some(v2) = self.info_hashes.v2
2944        {
2945            let v2_bytes: [u8; 20] = v2.0[..20]
2946                .try_into()
2947                .expect("Id32 is 32 bytes; first 20 always fit");
2948            let v2_as_v1 = Id20(v2_bytes);
2949
2950            if self.dht_v2_peers_rx.is_none()
2951                && let Some(ref dht) = self.dht
2952            {
2953                match dht.get_peers(v2_as_v1).await {
2954                    Ok(rx) => self.dht_v2_peers_rx = Some(rx),
2955                    Err(e) => debug!("DHT v4 v2-swarm re-query failed: {e}"),
2956                }
2957            }
2958            if self.dht_v6_v2_peers_rx.is_none()
2959                && self.dht_v6_empty_count < 30
2960                && self.should_retry_v6()
2961                && let Some(ref dht6) = self.dht_v6
2962            {
2963                self.dht_v6_last_retry = Some(std::time::Instant::now());
2964                match dht6.get_peers(v2_as_v1).await {
2965                    Ok(rx) => self.dht_v6_v2_peers_rx = Some(rx),
2966                    Err(e) => debug!("DHT v6 v2-swarm re-query failed: {e}"),
2967                }
2968            }
2969        }
2970
2971        debug!(peers = self.peers.len(), "DHT re-query triggered");
2972    }
2973
2974    /// Read a complete piece from disk by reading all chunks and concatenating.
2975    async fn handle_read_piece(&self, index: u32) -> crate::Result<Bytes> {
2976        let disk = self
2977            .disk
2978            .as_ref()
2979            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
2980        let lengths = self
2981            .lengths
2982            .as_ref()
2983            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
2984
2985        let piece_size = lengths.piece_size(index);
2986        if piece_size == 0 {
2987            return Err(crate::Error::InvalidPieceIndex {
2988                index,
2989                num_pieces: lengths.num_pieces(),
2990            });
2991        }
2992
2993        let chunk_size = lengths.chunk_size();
2994        let num_chunks = lengths.chunks_in_piece(index);
2995        let mut buf = bytes::BytesMut::with_capacity(piece_size as usize);
2996
2997        for chunk_idx in 0..num_chunks {
2998            let begin = chunk_idx * chunk_size;
2999            let len = if chunk_idx == num_chunks - 1 {
3000                piece_size - begin
3001            } else {
3002                chunk_size
3003            };
3004            let data = disk
3005                .read_chunk(index, begin, len, DiskJobFlags::empty())
3006                .await
3007                .map_err(crate::Error::Storage)?;
3008            buf.extend_from_slice(&data);
3009        }
3010
3011        Ok(buf.freeze())
3012    }
3013
3014    /// Flush the disk write cache.
3015    async fn handle_flush_cache(&self) -> crate::Result<()> {
3016        let disk = self
3017            .disk
3018            .as_ref()
3019            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3020        disk.flush_cache().await.map_err(crate::Error::Storage)
3021    }
3022
3023    /// Immediately initiate a connection to the given peer address.
3024    fn handle_connect_peer(&mut self, addr: SocketAddr) {
3025        // Skip if already connected
3026        if self.peers.contains_key(&addr) {
3027            return;
3028        }
3029        // M137: Track via unified PeerStates lifecycle
3030        if let Some(ref ps) = self.peer_states {
3031            ps.add_if_not_seen(addr, PeerSource::Incoming);
3032        }
3033    }
3034
3035    /// Fire TrackerReply / TrackerError alerts from announce outcomes.
3036    pub(crate) fn fire_tracker_alerts(&self, outcomes: &[crate::tracker_manager::TrackerOutcome]) {
3037        for outcome in outcomes {
3038            match &outcome.result {
3039                Ok(num_peers) => {
3040                    post_alert(
3041                        &self.alert_tx,
3042                        &self.alert_mask,
3043                        AlertKind::TrackerReply {
3044                            info_hash: self.info_hash,
3045                            url: outcome.url.clone(),
3046                            num_peers: *num_peers,
3047                        },
3048                    );
3049                }
3050                Err(msg) => {
3051                    post_alert(
3052                        &self.alert_tx,
3053                        &self.alert_mask,
3054                        AlertKind::TrackerError {
3055                            info_hash: self.info_hash,
3056                            url: outcome.url.clone(),
3057                            message: msg.clone(),
3058                        },
3059                    );
3060                }
3061            }
3062        }
3063    }
3064
3065    /// Calculate bytes remaining for tracker announce.
3066    pub(crate) fn calculate_left(&self) -> u64 {
3067        match (&self.meta, &self.chunk_tracker) {
3068            (Some(meta), Some(ct)) => {
3069                let total = meta.info.total_length();
3070                let have = ct.bitfield().count_ones() as u64;
3071                let pieces_total = self.num_pieces as u64;
3072                if pieces_total == 0 {
3073                    total
3074                } else {
3075                    total.saturating_sub(have * (total / pieces_total))
3076                }
3077            }
3078            _ => 0,
3079        }
3080    }
3081
3082    pub(crate) async fn shutdown_peers(&mut self) {
3083        // Best-effort announce Stopped to trackers (with timeout to prevent hang)
3084        let left = self.calculate_left();
3085        let _ = tokio::time::timeout(
3086            std::time::Duration::from_secs(3),
3087            self.tracker_manager
3088                .announce_stopped(self.uploaded, self.downloaded, left),
3089        )
3090        .await;
3091
3092        // Non-blocking peer shutdown — peers may already be dead or channels full
3093        for peer in self.peers.values() {
3094            let _ = peer.cmd_tx.try_send(PeerCommand::Shutdown);
3095        }
3096    }
3097
3098    // ----- Event handlers -----
3099
3100    pub(crate) async fn handle_piece_data(
3101        &mut self,
3102        peer_addr: SocketAddr,
3103        index: u32,
3104        begin: u32,
3105        data: Bytes,
3106    ) {
3107        // Skip duplicate blocks — in end-game mode or after timeout re-requests,
3108        // the same block may arrive from multiple peers. Writing it to the store
3109        // buffer would overwrite valid data that's pending verification.
3110        if let Some(ref ct) = self.chunk_tracker
3111            && ct.has_chunk(index, begin)
3112        {
3113            self.total_download += data.len() as u64 + 13;
3114            // Remove from pending_requests to free pipeline slots. Without this,
3115            // the peer accumulates phantom entries from already-verified pieces
3116            // and eventually has zero available pipeline slots — permanent stall.
3117            if let Some(peer) = self.peers.get_mut(&peer_addr) {
3118                peer.pending_requests.remove(index, begin);
3119            }
3120            // Remove from end-game tracker so pick_block won't return this
3121            // block again. The normal path calls block_received which does
3122            // this, but we skip that path for duplicates.
3123            if self.end_game.is_active() {
3124                self.end_game.block_received(index, begin, peer_addr);
3125            }
3126            // M75: Permit already returned by peer task on Piece receipt
3127            return;
3128        }
3129
3130        let data_len = data.len();
3131
3132        // M100: Deferred write via per-torrent writer task.
3133        if let Some(ref disk) = self.disk {
3134            disk.write_block_deferred(index, begin, data);
3135        }
3136
3137        self.downloaded += data_len as u64;
3138        self.total_download += data_len as u64 + 13; // payload + message header
3139        self.last_download = now_unix();
3140        self.need_save_resume = true;
3141
3142        // M93: Track piece ownership (actor learns about peer's CAS reservation via chunk arrival)
3143        if let Some(slab_idx) = self.peer_slab.slot_of(&peer_addr)
3144            && self.piece_owner.get(index as usize) == Some(&None)
3145        {
3146            self.piece_owner[index as usize] = Some(slab_idx);
3147            // M149: Track when piece started downloading
3148            if self.inflight_started.get(index as usize) == Some(&None) {
3149                self.inflight_started[index as usize] = Some(Instant::now());
3150            }
3151            // M103: Add to steal queue if piece has unrequested blocks
3152            if let (Some(sc), Some(bm)) = (&self.steal_candidates, &self.block_maps)
3153                && let Some(lengths) = &self.lengths
3154            {
3155                let total_blocks = lengths.chunks_in_piece(index);
3156                if bm.next_unrequested(index, total_blocks).is_some() {
3157                    sc.push(index);
3158                }
3159            }
3160        }
3161
3162        // Smart banning: track which peers contribute to each piece
3163        self.piece_contributors
3164            .entry(index)
3165            .or_default()
3166            .insert(peer_addr.ip());
3167
3168        let now = std::time::Instant::now();
3169        if let Some(peer) = self.peers.get_mut(&peer_addr) {
3170            peer.pending_requests.remove(index, begin);
3171            peer.download_bytes_window += data_len as u64;
3172            peer.download_bytes_total += data_len as u64;
3173            peer.pipeline
3174                .block_received(index, begin, data_len as u32, now);
3175            peer.last_data_received = Some(now);
3176            // Clear snub if snubbed
3177            if peer.snubbed {
3178                peer.snubbed = false;
3179            }
3180        }
3181        // M137: Backoff is now automatically reset by mark_live() in PeerStates.
3182
3183        // End-game: cancel this block on all other peers. The 200ms end-game
3184        // refill tick will re-stock freed peers — no reactive cascade needed.
3185        if self.end_game.is_active() {
3186            let cancels = self.end_game.block_received(index, begin, peer_addr);
3187            for (cancel_addr, ci, cb, cl) in cancels {
3188                if let Some(cancel_peer) = self.peers.get_mut(&cancel_addr) {
3189                    let _ = cancel_peer.cmd_tx.try_send(PeerCommand::Cancel {
3190                        index: ci,
3191                        begin: cb,
3192                        length: cl,
3193                    });
3194                    cancel_peer.pending_requests.remove(ci, cb);
3195                }
3196            }
3197        }
3198
3199        // Track chunk completion
3200        let piece_complete = if let Some(ref mut ct) = self.chunk_tracker {
3201            ct.chunk_received(index, begin)
3202        } else {
3203            false
3204        };
3205
3206        if piece_complete && !self.pending_verify.contains(&index) {
3207            // M44/M118: Predictive piece announce — broadcast Have before verification
3208            if self.config.predictive_piece_announce_ms > 0
3209                && !self.predictive_have_sent.contains(&index)
3210            {
3211                self.predictive_have_sent.insert(index);
3212                let _ = self.have_broadcast_tx.send(index);
3213            }
3214
3215            // M100: Flush deferred writes before verification — ensures all
3216            // blocks are on disk so read_piece() sees complete data.
3217            if let Some(ref disk) = self.disk {
3218                disk.flush_piece_writes(index).await;
3219            }
3220
3221            match self.version {
3222                irontide_core::TorrentVersion::V1Only => {
3223                    // Async: fire-and-forget, result via verify_result_rx
3224                    if let Some(ref disk) = self.disk
3225                        && let Some(expected) = self
3226                            .meta
3227                            .as_ref()
3228                            .and_then(|m| m.info.piece_hash(index as usize))
3229                    {
3230                        self.pending_verify.insert(index);
3231                        let generation = self
3232                            .piece_generations
3233                            .get(index as usize)
3234                            .copied()
3235                            .unwrap_or(0);
3236                        disk.enqueue_verify(index, expected, generation, &self.verify_result_tx);
3237                    }
3238                }
3239                irontide_core::TorrentVersion::V2Only => {
3240                    // Blocking: needs mutable hash_picker for Merkle tree
3241                    self.verify_and_mark_piece_v2(index).await;
3242                }
3243                irontide_core::TorrentVersion::Hybrid => {
3244                    // Blocking: needs both v1+v2 decision matrix
3245                    self.verify_and_mark_piece_hybrid(index).await;
3246                }
3247            }
3248        }
3249
3250        // M75: Permit already returned by peer task on Piece receipt.
3251        // End-game dispatch still happens here.
3252        if self.end_game.is_active() {
3253            self.request_end_game_block(peer_addr).await;
3254        }
3255    }
3256
3257    /// M92: Process a batch of block completions from a single peer.
3258    /// Iterates blocks, calling `process_block_completion()` for each.
3259    /// Piece verifications are triggered inline as pieces complete
3260    /// (same as the former per-block path).
3261    pub(crate) async fn handle_piece_blocks_batch(
3262        &mut self,
3263        peer_addr: SocketAddr,
3264        blocks: Vec<crate::types::BlockEntry>,
3265    ) {
3266        for block in &blocks {
3267            self.process_block_completion(peer_addr, block.index, block.begin, block.length)
3268                .await;
3269        }
3270    }
3271
3272    fn handle_open_file(
3273        &mut self,
3274        file_index: usize,
3275    ) -> crate::Result<crate::streaming::FileStreamHandle> {
3276        let meta = self
3277            .meta
3278            .as_ref()
3279            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3280        let files = meta.info.files();
3281        if file_index >= files.len() {
3282            return Err(crate::Error::InvalidFileIndex {
3283                index: file_index,
3284                count: files.len(),
3285            });
3286        }
3287        if self.file_priorities.get(file_index).copied() == Some(FilePriority::Skip) {
3288            return Err(crate::Error::FileSkipped { index: file_index });
3289        }
3290
3291        let lengths = self
3292            .lengths
3293            .as_ref()
3294            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3295        let disk = self
3296            .disk
3297            .as_ref()
3298            .ok_or(crate::Error::MetadataNotReady(self.info_hash))?;
3299
3300        // Compute file offset within torrent data
3301        let mut file_offset = 0u64;
3302        for f in &files[..file_index] {
3303            file_offset += f.length;
3304        }
3305        let file_length = files[file_index].length;
3306
3307        let (cursor_tx, cursor_rx) = tokio::sync::watch::channel(0u64);
3308
3309        let permit = self
3310            .stream_read_semaphore
3311            .clone()
3312            .try_acquire_owned()
3313            .map_err(|_| crate::Error::Connection("too many concurrent stream readers".into()))?;
3314
3315        // Add streaming cursor for the actor to track
3316        self.streaming_cursors
3317            .push(crate::streaming::StreamingCursor {
3318                file_index,
3319                file_offset,
3320                cursor_piece: (file_offset / lengths.piece_length()) as u32,
3321                readahead_pieces: self.config.readahead_pieces,
3322                cursor_rx,
3323            });
3324
3325        Ok(crate::streaming::FileStreamHandle {
3326            disk: disk.clone(),
3327            lengths: lengths.clone(),
3328            file_index,
3329            file_offset,
3330            file_length,
3331            cursor_tx,
3332            piece_ready_rx: self.piece_ready_tx.subscribe(),
3333            have: self.have_watch_rx.clone(),
3334            read_permit: permit,
3335        })
3336    }
3337
3338    /// M44: Suggest cached pieces to connected peers (BEP 6).
3339    async fn suggest_cached_pieces(&mut self) {
3340        if !self.config.suggest_mode {
3341            return;
3342        }
3343        let disk = match self.disk {
3344            Some(ref d) => d.clone(),
3345            None => return,
3346        };
3347        let cached = disk.cached_pieces().await;
3348        if cached.is_empty() {
3349            return;
3350        }
3351        let max_suggest = self.config.max_suggest_pieces;
3352        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
3353        for peer_addr in peer_addrs {
3354            let already_suggested = self.suggested_to_peers.entry(peer_addr).or_default();
3355            let peer_has_piece = |piece: u32| -> bool {
3356                self.peers
3357                    .get(&peer_addr)
3358                    .is_some_and(|p| p.bitfield.get(piece))
3359            };
3360            let mut sent = 0;
3361            for &piece in &cached {
3362                if sent >= max_suggest {
3363                    break;
3364                }
3365                if peer_has_piece(piece) {
3366                    continue;
3367                }
3368                if already_suggested.contains(&piece) {
3369                    continue;
3370                }
3371                if let Some(peer) = self.peers.get(&peer_addr) {
3372                    let _ = peer.cmd_tx.try_send(PeerCommand::SuggestPiece(piece));
3373                    already_suggested.insert(piece);
3374                    sent += 1;
3375                }
3376            }
3377        }
3378    }
3379
3380    /// M147: Handle pre-resolved metadata from the background resolver.
3381    ///
3382    /// If the TorrentActor is still in `FetchingMetadata` state, feed the
3383    /// info bytes through `MetadataDownloader` and call `try_assemble_metadata()`.
3384    /// If already past that state (actor resolved first), silently ignore.
3385    async fn handle_pre_resolved_metadata(&mut self, info_bytes: Vec<u8>, peers: Vec<SocketAddr>) {
3386        // Only act if still fetching metadata — actor may have resolved first.
3387        if self.state != TorrentState::FetchingMetadata {
3388            debug!(
3389                info_hash = %self.info_hash,
3390                state = ?self.state,
3391                "ignoring pre-resolved metadata: already past FetchingMetadata"
3392            );
3393            return;
3394        }
3395
3396        debug!(
3397            info_hash = %self.info_hash,
3398            info_bytes_len = info_bytes.len(),
3399            num_peers = peers.len(),
3400            "received pre-resolved metadata from background resolver"
3401        );
3402
3403        // Feed the complete info bytes to the MetadataDownloader.
3404        if let Some(ref mut dl) = self.metadata_downloader {
3405            // Set total size so the downloader knows the expected piece count.
3406            dl.set_total_size(info_bytes.len() as u64);
3407
3408            // Feed as a single piece (piece 0) containing the full info dict.
3409            // For metadata smaller than 16 KiB this is a single piece.
3410            // For larger metadata, feed each 16 KiB chunk as a separate piece.
3411            let piece_size: usize = 16384;
3412            let num_pieces = info_bytes.len().div_ceil(piece_size);
3413            for i in 0..num_pieces {
3414                let start = i * piece_size;
3415                let end = (start + piece_size).min(info_bytes.len());
3416                let data = bytes::Bytes::copy_from_slice(&info_bytes[start..end]);
3417                dl.piece_received(i as u32, data);
3418            }
3419        }
3420
3421        // Attempt assembly — this will transition to Downloading if
3422        // the info_hash validates.
3423        self.try_assemble_metadata().await;
3424
3425        // Pre-seed discovered peers into the pipeline.
3426        if !peers.is_empty() {
3427            self.handle_add_peers(peers, crate::peer_state::PeerSource::Dht);
3428        }
3429    }
3430
3431    pub(crate) async fn try_assemble_metadata(&mut self) {
3432        let assembled = if let Some(ref dl) = self.metadata_downloader {
3433            dl.assemble_and_verify()
3434        } else {
3435            return;
3436        };
3437
3438        match assembled {
3439            Ok(info_bytes) => {
3440                // Build torrent bytes wrapping the raw info dict into a minimal torrent
3441                // We need to parse it as a full torrent. The info_bytes is the raw bencoded
3442                // info dict. We'll build a minimal torrent around it.
3443                // Actually, torrent_from_bytes expects a full torrent dict.
3444                // Let's build one:
3445                let mut torrent_bytes = b"d4:info".to_vec();
3446                torrent_bytes.extend_from_slice(&info_bytes);
3447                torrent_bytes.push(b'e');
3448
3449                match torrent_from_bytes(&torrent_bytes) {
3450                    Ok(meta) => {
3451                        let num_pieces = meta.info.num_pieces() as u32;
3452                        let lengths = Lengths::new(
3453                            meta.info.total_length(),
3454                            meta.info.piece_length,
3455                            DEFAULT_CHUNK_SIZE,
3456                        );
3457
3458                        // Create filesystem storage now that we know the file layout
3459                        let files = meta.info.files();
3460                        let file_paths: Vec<std::path::PathBuf> = files
3461                            .iter()
3462                            .map(|f| f.path.iter().collect::<std::path::PathBuf>())
3463                            .collect();
3464                        let file_lengths_vec: Vec<u64> = files.iter().map(|f| f.length).collect();
3465                        let prealloc_mode = self.config.preallocate_mode.unwrap_or_else(|| {
3466                            irontide_storage::PreallocateMode::from(
3467                                self.config.storage_mode == irontide_core::StorageMode::Full,
3468                            )
3469                        });
3470                        let storage: Arc<dyn TorrentStorage> =
3471                            match irontide_storage::FilesystemStorage::new(
3472                                &self.config.download_dir,
3473                                file_paths,
3474                                file_lengths_vec,
3475                                lengths.clone(),
3476                                None,
3477                                prealloc_mode,
3478                                self.config.filesystem_direct_io,
3479                            ) {
3480                                Ok(s) => Arc::new(s),
3481                                Err(e) => {
3482                                    warn!(
3483                                        "failed to create filesystem storage: {e}, falling back to memory"
3484                                    );
3485                                    Arc::new(MemoryStorage::new(lengths.clone()))
3486                                }
3487                            };
3488                        let mut disk_handle = self
3489                            .disk_manager
3490                            .register_torrent(self.info_hash, storage)
3491                            .await;
3492
3493                        self.chunk_tracker = Some(ChunkTracker::new(lengths.clone()));
3494                        self.lengths = Some(lengths);
3495                        self.num_pieces = num_pieces;
3496                        // M96: Initialize real generation counters + hash result channel
3497                        self.piece_generations = vec![0u64; num_pieces as usize];
3498                        let (hash_tx, hash_rx) = tokio::sync::mpsc::channel(64);
3499                        self.hash_result_tx = hash_tx;
3500                        self.hash_result_rx = hash_rx;
3501                        // M96: Wire hash pool into disk handle (version check deferred
3502                        // until after metadata detection below sets self.version)
3503                        if let Some(ref pool) = self.hash_pool_ref {
3504                            disk_handle.set_hash_pool(pool.clone());
3505                            disk_handle.set_hash_result_tx(self.hash_result_tx.clone());
3506                        }
3507                        self.disk = Some(disk_handle);
3508                        // Update all connected peer tasks so they can validate
3509                        // incoming Bitfield messages with the correct piece count.
3510                        for peer in self.peers.values() {
3511                            let _ = peer
3512                                .cmd_tx
3513                                .try_send(PeerCommand::UpdateNumPieces(num_pieces));
3514                        }
3515                        let file_lengths: Vec<u64> =
3516                            meta.info.files().iter().map(|f| f.length).collect();
3517                        let mut meta = meta;
3518                        meta.info_bytes = Some(Bytes::from(info_bytes));
3519                        self.meta = Some(meta);
3520
3521                        // M116: Populate cached file info for zero-alloc completion checks.
3522                        if let (Some(meta), Some(lengths)) = (&self.meta, &self.lengths) {
3523                            self.cached_files = Some(build_cached_file_info(meta, lengths));
3524                        }
3525
3526                        self.file_priorities = vec![FilePriority::Normal; file_lengths.len()];
3527
3528                        // BEP 53: apply magnet so= file selection
3529                        if let Some(ref selections) = self.magnet_selected_files {
3530                            self.file_priorities = irontide_core::FileSelection::to_priorities(
3531                                selections,
3532                                file_lengths.len(),
3533                            );
3534                            self.magnet_selected_files = None;
3535                        }
3536
3537                        self.wanted_pieces = crate::piece_selector::build_wanted_pieces(
3538                            &self.file_priorities,
3539                            &file_lengths,
3540                            self.lengths.as_ref().unwrap(),
3541                        );
3542                        if self.config.share_mode {
3543                            self.transition_state(TorrentState::Sharing);
3544                        } else {
3545                            self.transition_state(TorrentState::Downloading);
3546                        }
3547                        self.metadata_downloader = None;
3548
3549                        // Populate tracker manager with newly parsed metadata
3550                        if let Some(ref meta) = self.meta {
3551                            self.tracker_manager
3552                                .set_metadata_filtered(meta, &self.config.url_security);
3553                        }
3554
3555                        // Detect hybrid/v2 from metadata and update dual-swarm state
3556                        // (Gap 1 & 2: propagate info_hashes to tracker + DHT after magnet resolves)
3557                        if let Ok(detected) = irontide_core::torrent_from_bytes_any(&torrent_bytes)
3558                        {
3559                            let new_version = detected.version();
3560                            if new_version != irontide_core::TorrentVersion::V1Only {
3561                                let new_hashes = detected.info_hashes();
3562                                self.version = new_version;
3563                                self.info_hashes = new_hashes.clone();
3564                                self.tracker_manager.set_info_hashes(new_hashes.clone());
3565                                if let Some(v2_meta) = detected.as_v2() {
3566                                    self.meta_v2 = Some(v2_meta.clone());
3567                                }
3568                                // Start v2 DHT lookups for hybrid torrents
3569                                if new_hashes.is_hybrid()
3570                                    && let Some(v2) = new_hashes.v2
3571                                {
3572                                    let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
3573                                    if v2_as_v1 != self.info_hash {
3574                                        if self.dht_v2_peers_rx.is_none()
3575                                            && let Some(ref dht) = self.dht
3576                                            && let Ok(rx) = dht.get_peers(v2_as_v1).await
3577                                        {
3578                                            self.dht_v2_peers_rx = Some(rx);
3579                                        }
3580                                        if self.dht_v6_v2_peers_rx.is_none()
3581                                            && self.dht_v6_empty_count < 30
3582                                            && self.should_retry_v6()
3583                                            && let Some(ref dht6) = self.dht_v6
3584                                            && let Ok(rx) = dht6.get_peers(v2_as_v1).await
3585                                        {
3586                                            self.dht_v6_last_retry =
3587                                                Some(std::time::Instant::now());
3588                                            self.dht_v6_v2_peers_rx = Some(rx);
3589                                        }
3590                                    }
3591                                }
3592                            }
3593                        }
3594
3595                        let name = self
3596                            .meta
3597                            .as_ref()
3598                            .map(|m| m.info.name.clone())
3599                            .unwrap_or_default();
3600                        post_alert(
3601                            &self.alert_tx,
3602                            &self.alert_mask,
3603                            AlertKind::MetadataReceived {
3604                                info_hash: self.info_hash,
3605                                name,
3606                            },
3607                        );
3608                        info!("metadata assembled, switching to Downloading");
3609
3610                        // M93: Initialize lock-free piece states after metadata
3611                        if let Some(ct) = &self.chunk_tracker {
3612                            let atomic_states = Arc::new(AtomicPieceStates::new(
3613                                self.num_pieces,
3614                                ct.bitfield(),
3615                                &self.wanted_pieces,
3616                            ));
3617                            self.atomic_states = Some(Arc::clone(&atomic_states));
3618                            self.availability = vec![0u32; self.num_pieces as usize];
3619                            self.piece_owner = vec![None; self.num_pieces as usize];
3620                            // M149: Initialize inflight tracking
3621                            self.inflight_started = vec![None; self.num_pieces as usize];
3622                            self.max_in_flight = self.config.max_in_flight_pieces;
3623
3624                            // M103: Initialize block stealing infrastructure
3625                            if self.config.use_block_stealing {
3626                                if let Some(ref lengths) = self.lengths {
3627                                    self.block_maps =
3628                                        Some(Arc::new(BlockMaps::new(self.num_pieces, lengths)));
3629                                }
3630                                self.steal_candidates = Some(Arc::new(StealCandidates::new()));
3631                            }
3632                            // M120: Per-piece write guards
3633                            self.piece_write_guards = Some(Arc::new(
3634                                crate::piece_reservation::PieceWriteGuards::new(self.num_pieces),
3635                            ));
3636
3637                            let snapshot = Arc::new(AvailabilitySnapshot::build(
3638                                &self.availability,
3639                                &atomic_states,
3640                                &self.priority_pieces,
3641                                0,
3642                            ));
3643                            self.availability_snapshot = Some(snapshot);
3644                            self.snapshot_generation = 0;
3645
3646                            let notify = Arc::new(tokio::sync::Notify::new());
3647                            self.reservation_notify = Some(notify);
3648                        }
3649
3650                        // Start web seeds now that we have metadata
3651                        self.spawn_web_seeds();
3652                        self.assign_pieces_to_web_seeds();
3653
3654                        // Kick-start piece requesting for all peers that connected during
3655                        // metadata phase. Send StartRequesting to all connected peers.
3656                        let peer_addrs: Vec<SocketAddr> = self.peers.keys().copied().collect();
3657                        info!(
3658                            connected_peers = peer_addrs.len(),
3659                            "kick-starting piece requests for pre-connected peers"
3660                        );
3661                        for addr in peer_addrs {
3662                            let has_bitfield = self
3663                                .peers
3664                                .get(&addr)
3665                                .map(|p| p.bitfield.count_ones())
3666                                .unwrap_or(0);
3667                            let is_choking = self
3668                                .peers
3669                                .get(&addr)
3670                                .map(|p| p.peer_choking)
3671                                .unwrap_or(true);
3672                            debug!(%addr, has_bitfield, is_choking, "post-metadata peer state");
3673                            self.maybe_express_interest(addr).await;
3674                            // M93: Update availability from pre-connected peer
3675                            if let Some(peer) = self.peers.get(&addr)
3676                                && peer.bitfield.count_ones() > 0
3677                            {
3678                                for piece in 0..self.num_pieces {
3679                                    if peer.bitfield.get(piece) {
3680                                        self.availability[piece as usize] += 1;
3681                                    }
3682                                }
3683                                let _slot = self.peer_slab.insert(addr);
3684                            }
3685                        }
3686                        self.recalc_max_in_flight();
3687                        self.rebuild_availability_snapshot();
3688                        // M93: Inform all connected peers about lock-free dispatch state.
3689                        // M159: Skip while user seed mode is active — we are currently
3690                        // not scheduling any new block requests.
3691                        if !self.user_seed_mode
3692                            && let (Some(atomic_states), Some(snapshot), Some(notify)) = (
3693                                &self.atomic_states,
3694                                &self.availability_snapshot,
3695                                &self.reservation_notify,
3696                            )
3697                            && let Some(ref lengths) = self.lengths
3698                        {
3699                            for peer in self.peers.values() {
3700                                let _ = peer.cmd_tx.try_send(PeerCommand::StartRequesting {
3701                                    atomic_states: Arc::clone(atomic_states),
3702                                    availability_snapshot: Arc::clone(snapshot),
3703                                    piece_notify: Arc::clone(notify),
3704                                    disk_handle: self.disk.clone(),
3705                                    write_error_tx: self.write_error_tx.clone(),
3706                                    lengths: lengths.clone(),
3707                                    block_maps: self.block_maps.clone(),
3708                                    steal_candidates: self.steal_candidates.clone(),
3709                                    piece_write_guards: self.piece_write_guards.clone(),
3710                                });
3711                            }
3712                        }
3713                    }
3714                    Err(e) => {
3715                        warn!("failed to parse assembled metadata: {e}");
3716                        post_alert(
3717                            &self.alert_tx,
3718                            &self.alert_mask,
3719                            AlertKind::MetadataFailed {
3720                                info_hash: self.info_hash,
3721                            },
3722                        );
3723                    }
3724                }
3725            }
3726            Err(e) => {
3727                warn!("metadata assembly failed: {e}");
3728                post_alert(
3729                    &self.alert_tx,
3730                    &self.alert_mask,
3731                    AlertKind::MetadataFailed {
3732                        info_hash: self.info_hash,
3733                    },
3734                );
3735            }
3736        }
3737    }
3738
3739    // ----- Web seeding (M22) -----
3740
3741    fn spawn_web_seeds(&mut self) {
3742        if !self.config.enable_web_seed {
3743            return;
3744        }
3745        let meta = match &self.meta {
3746            Some(m) => m,
3747            None => return,
3748        };
3749        let lengths = match &self.lengths {
3750            Some(l) => l.clone(),
3751            None => return,
3752        };
3753
3754        let file_lengths: Vec<u64> = meta.info.files().iter().map(|f| f.length).collect();
3755        let file_map = irontide_storage::FileMap::new(file_lengths, lengths.clone());
3756
3757        // BEP 19 (GetRight) web seeds
3758        for url in &meta.url_list {
3759            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
3760                continue;
3761            }
3762            if self.web_seeds.len() >= self.config.max_web_seeds {
3763                break;
3764            }
3765
3766            // Security validation
3767            if let Err(e) = crate::url_guard::validate_web_seed_url(url, &self.config.url_security)
3768            {
3769                warn!(%url, %e, "web seed URL rejected by security policy");
3770                continue;
3771            }
3772
3773            let url_builder = if meta.info.length.is_some() {
3774                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone())
3775            } else {
3776                let file_paths: Vec<String> = meta
3777                    .info
3778                    .files()
3779                    .iter()
3780                    .map(|f| f.path[1..].join("/")) // skip torrent name prefix
3781                    .collect();
3782                crate::web_seed::WebSeedUrlBuilder::multi(
3783                    url.clone(),
3784                    meta.info.name.clone(),
3785                    file_paths,
3786                )
3787            };
3788
3789            let (cmd_tx, cmd_rx) = mpsc::channel(16);
3790            let task = crate::web_seed::WebSeedTask::new(
3791                url.clone(),
3792                crate::web_seed::WebSeedMode::GetRight,
3793                url_builder,
3794                lengths.clone(),
3795                file_map.clone(),
3796                self.info_hash,
3797                cmd_rx,
3798                self.event_tx.clone(),
3799                &self.config.url_security,
3800            );
3801            tokio::spawn(task.run());
3802            self.web_seeds.insert(url.clone(), cmd_tx);
3803            debug!(url, "spawned BEP 19 web seed");
3804        }
3805
3806        // BEP 17 (Hoffman) HTTP seeds
3807        for url in &meta.httpseeds {
3808            if self.banned_web_seeds.contains(url) || self.web_seeds.contains_key(url) {
3809                continue;
3810            }
3811            if self.web_seeds.len() >= self.config.max_web_seeds {
3812                break;
3813            }
3814
3815            // Security validation
3816            if let Err(e) = crate::url_guard::validate_web_seed_url(url, &self.config.url_security)
3817            {
3818                warn!(%url, %e, "web seed URL rejected by security policy");
3819                continue;
3820            }
3821
3822            // BEP 17 doesn't use URL builder for per-file paths; it sends parameterized URLs
3823            let url_builder =
3824                crate::web_seed::WebSeedUrlBuilder::single(url.clone(), meta.info.name.clone());
3825
3826            let (cmd_tx, cmd_rx) = mpsc::channel(16);
3827            let task = crate::web_seed::WebSeedTask::new(
3828                url.clone(),
3829                crate::web_seed::WebSeedMode::Hoffman,
3830                url_builder,
3831                lengths.clone(),
3832                file_map.clone(),
3833                self.info_hash,
3834                cmd_rx,
3835                self.event_tx.clone(),
3836                &self.config.url_security,
3837            );
3838            tokio::spawn(task.run());
3839            self.web_seeds.insert(url.clone(), cmd_tx);
3840            debug!(url, "spawned BEP 17 web seed");
3841        }
3842    }
3843
3844    fn assign_pieces_to_web_seeds(&mut self) {
3845        if self.state != TorrentState::Downloading || self.end_game.is_active() {
3846            return;
3847        }
3848
3849        // Collect idle web seed URLs (not currently downloading a piece)
3850        let active_urls: HashSet<&String> = self.web_seed_in_flight.values().collect();
3851        let idle_urls: Vec<String> = self
3852            .web_seeds
3853            .keys()
3854            .filter(|u| !active_urls.contains(u))
3855            .cloned()
3856            .collect();
3857
3858        let ct = match &self.chunk_tracker {
3859            Some(ct) => ct,
3860            None => return,
3861        };
3862
3863        for url in idle_urls {
3864            // Find lowest-index piece that is: not verified, not reserved by a peer,
3865            // not in web_seed_in_flight, and wanted.
3866            let piece = (0..self.num_pieces).find(|&i| {
3867                !ct.has_piece(i)
3868                    && !self
3869                        .piece_owner
3870                        .get(i as usize)
3871                        .is_some_and(|o| o.is_some())
3872                    && !self.web_seed_in_flight.contains_key(&i)
3873                    && self.wanted_pieces.get(i)
3874            });
3875
3876            if let Some(piece) = piece
3877                && let Some(cmd_tx) = self.web_seeds.get(&url)
3878            {
3879                let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::FetchPiece(piece));
3880                self.web_seed_in_flight.insert(piece, url);
3881            }
3882        }
3883    }
3884
3885    pub(crate) async fn handle_web_seed_piece_data(
3886        &mut self,
3887        url: String,
3888        index: u32,
3889        data: Bytes,
3890    ) {
3891        self.web_seed_in_flight.remove(&index);
3892
3893        // If peer already completed this piece, discard
3894        if let Some(ref ct) = self.chunk_tracker
3895            && ct.has_piece(index)
3896        {
3897            self.assign_pieces_to_web_seeds();
3898            return;
3899        }
3900
3901        // Write entire piece to disk at offset 0
3902        if let Some(ref disk) = self.disk
3903            && let Err(e) = disk
3904                .write_chunk(index, 0, data.clone(), DiskJobFlags::FLUSH_PIECE)
3905                .await
3906        {
3907            warn!(index, "web seed: failed to write piece: {e}");
3908            self.assign_pieces_to_web_seeds();
3909            return;
3910        }
3911
3912        // Mark all chunks as received
3913        if let Some(ref mut ct) = self.chunk_tracker
3914            && let Some(ref lengths) = self.lengths
3915        {
3916            let num_chunks = lengths.chunks_in_piece(index);
3917            for chunk_idx in 0..num_chunks {
3918                if let Some((begin, _len)) = lengths.chunk_info(index, chunk_idx) {
3919                    ct.chunk_received(index, begin);
3920                }
3921            }
3922        }
3923
3924        self.downloaded += data.len() as u64;
3925        self.total_download += data.len() as u64 + 13; // payload + message header
3926        self.last_download = now_unix();
3927        self.need_save_resume = true;
3928
3929        // Verify the piece hash
3930        self.verify_and_mark_piece(index).await;
3931
3932        // If hash failed, ban this web seed (BEP 19 spec)
3933        if let Some(ref ct) = self.chunk_tracker
3934            && !ct.has_piece(index)
3935        {
3936            self.ban_web_seed(&url);
3937            return;
3938        }
3939
3940        self.assign_pieces_to_web_seeds();
3941    }
3942
3943    pub(crate) fn handle_web_seed_error(&mut self, url: String, piece: u32, message: String) {
3944        self.web_seed_in_flight.remove(&piece);
3945        warn!(%url, piece, %message, "web seed error");
3946        self.assign_pieces_to_web_seeds();
3947    }
3948
3949    fn ban_web_seed(&mut self, url: &str) {
3950        warn!(%url, "banning web seed due to hash failure");
3951        self.banned_web_seeds.insert(url.to_owned());
3952
3953        // Send shutdown to the task
3954        if let Some(cmd_tx) = self.web_seeds.remove(url) {
3955            let _ = cmd_tx.try_send(crate::web_seed::WebSeedCommand::Shutdown);
3956        }
3957
3958        // Remove all in-flight pieces for this URL
3959        self.web_seed_in_flight.retain(|_, v| v != url);
3960
3961        post_alert(
3962            &self.alert_tx,
3963            &self.alert_mask,
3964            AlertKind::WebSeedBanned {
3965                info_hash: self.info_hash,
3966                url: url.to_owned(),
3967            },
3968        );
3969    }
3970
3971    async fn shutdown_web_seeds(&mut self) {
3972        for (_, cmd_tx) in self.web_seeds.drain() {
3973            let _ = cmd_tx.send(crate::web_seed::WebSeedCommand::Shutdown).await;
3974        }
3975        self.web_seed_in_flight.clear();
3976    }
3977
3978    /// Rebuild the cached peer rates map from current peer state.
3979    fn refresh_peer_rates(&mut self) {
3980        self.cached_peer_rates.clear();
3981        self.cached_peer_rates.reserve(self.peers.len());
3982        for (&addr, p) in &self.peers {
3983            self.cached_peer_rates.insert(addr, p.pipeline.ewma_rate());
3984        }
3985    }
3986
3987    // ----- Choking -----
3988
3989    fn update_peer_rates(&mut self) {
3990        for peer in self.peers.values_mut() {
3991            // Window is 10 seconds (unchoke interval)
3992            peer.download_rate = peer.download_bytes_window / 10;
3993            peer.upload_rate = peer.upload_bytes_window / 10;
3994            peer.download_bytes_window = 0;
3995            peer.upload_bytes_window = 0;
3996
3997            // M149: Compute dynamic pipeline depth from throughput.
3998            // target = (bytes_per_sec / 16384) * target_buffer_secs
3999            // = number of blocks to keep in-flight to fill the peer's pipe.
4000            // The semaphore caps at INITIAL_QUEUE_DEPTH (128), so values above
4001            // 128 are effectively capped by the semaphore. The benefit is
4002            // downward pressure: slow peers get capped below 128.
4003            // Wake path: depth_notify on PeerShared — reader fires on every
4004            // in_flight decrement so the gate has zero wake latency.
4005            let target =
4006                ((peer.download_rate as f64 / 16384.0) * self.config.target_buffer_secs) as u32;
4007            let clamped = target.clamp(
4008                self.config.min_pipeline_depth,
4009                self.config.max_pipeline_depth,
4010            );
4011            peer.target_depth
4012                .store(clamped, std::sync::atomic::Ordering::Relaxed);
4013        }
4014
4015        // Track peak download rate for peer turnover cutoff
4016        let aggregate_download: u64 = self.peers.values().map(|p| p.download_rate).sum();
4017        if aggregate_download > self.peak_download_rate {
4018            self.peak_download_rate = aggregate_download;
4019        }
4020    }
4021
4022    async fn run_choker(&mut self) {
4023        let peer_infos: Vec<ChokerPeerInfo> = self
4024            .peers
4025            .values()
4026            .map(|p| ChokerPeerInfo {
4027                addr: p.addr,
4028                download_rate: p.download_rate,
4029                upload_rate: p.upload_rate,
4030                interested: p.peer_interested,
4031                upload_only: p.upload_only,
4032                is_seed: p.upload_only
4033                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
4034            })
4035            .collect();
4036
4037        let decision = self.choker.decide(&peer_infos);
4038
4039        for addr in &decision.to_unchoke {
4040            if let Some(peer) = self.peers.get_mut(addr)
4041                && peer.am_choking
4042            {
4043                peer.am_choking = false;
4044                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(false));
4045            }
4046        }
4047
4048        for addr in &decision.to_choke {
4049            if let Some(peer) = self.peers.get_mut(addr)
4050                && !peer.am_choking
4051            {
4052                if peer.supports_fast {
4053                    let pending: Vec<(u32, u32, u32)> = peer.incoming_requests.drain(..).collect();
4054                    for (index, begin, length) in pending {
4055                        let _ = peer.cmd_tx.try_send(PeerCommand::RejectRequest {
4056                            index,
4057                            begin,
4058                            length,
4059                        });
4060                    }
4061                }
4062                peer.am_choking = true;
4063                let _ = peer.cmd_tx.try_send(PeerCommand::SetChoking(true));
4064            }
4065        }
4066
4067        // Serve any buffered requests from newly-unchoked peers
4068        self.serve_incoming_requests().await;
4069
4070        // Zombie pruning: disconnect peers with empty bitfields after 30s.
4071        // These peers consume connection slots but contribute no pieces.
4072        // Only prune during downloading — when seeding, empty-bitfield peers
4073        // are leechers we want to upload to.
4074        if self.state == TorrentState::Downloading {
4075            let zombie_threshold = Duration::from_secs(30);
4076            let zombies: Vec<SocketAddr> = self
4077                .peers
4078                .values()
4079                .filter(|p| {
4080                    p.bitfield.count_ones() == 0 && p.connected_at.elapsed() > zombie_threshold
4081                })
4082                .map(|p| p.addr)
4083                .collect();
4084
4085            for &addr in &zombies {
4086                debug!(%addr, "disconnecting zombie peer (empty bitfield after 30s)");
4087                self.disconnect_peer(addr, "zombie peer (empty bitfield)");
4088            }
4089            if !zombies.is_empty() {
4090                self.recalc_max_in_flight();
4091                self.mark_snapshot_dirty();
4092            }
4093        }
4094    }
4095
4096    fn rotate_optimistic(&mut self) {
4097        let peer_infos: Vec<ChokerPeerInfo> = self
4098            .peers
4099            .values()
4100            .map(|p| ChokerPeerInfo {
4101                addr: p.addr,
4102                download_rate: p.download_rate,
4103                upload_rate: p.upload_rate,
4104                interested: p.peer_interested,
4105                upload_only: p.upload_only,
4106                is_seed: p.upload_only
4107                    || (self.num_pieces > 0 && p.bitfield.count_ones() == self.num_pieces),
4108            })
4109            .collect();
4110
4111        self.choker.rotate_optimistic(&peer_infos);
4112    }
4113
4114    /// Handle an incoming I2P peer connection (M41).
4115    ///
4116    /// Assigns a synthetic `SocketAddr` (from the reserved 240.0.0.0/4 range) since
4117    /// I2P peers don't have real IP addresses, then hands the underlying TCP stream
4118    /// to `spawn_peer_from_stream`.
4119    fn handle_i2p_incoming(&mut self, stream: crate::i2p::SamStream) {
4120        if self.peers.len() >= self.effective_max_connections() {
4121            return;
4122        }
4123
4124        let synthetic_addr = self.next_i2p_synthetic_addr();
4125
4126        let remote_dest = stream.remote_destination().clone();
4127        let dest_preview = {
4128            let b64 = remote_dest.to_base64();
4129            if b64.len() >= 8 {
4130                b64[..8].to_string()
4131            } else {
4132                b64
4133            }
4134        };
4135        self.i2p_destinations.insert(synthetic_addr, remote_dest);
4136        let tcp_stream = stream.into_inner();
4137
4138        self.spawn_peer_from_stream(synthetic_addr, tcp_stream);
4139
4140        debug!(dest = %dest_preview, addr = %synthetic_addr, "accepted I2P peer");
4141    }
4142
4143    /// Add an I2P peer by destination, assigning a synthetic SocketAddr.
4144    #[allow(dead_code)] // Used by Task 2 (outbound I2P connects)
4145    fn add_i2p_peer(
4146        &mut self,
4147        dest: crate::i2p::I2pDestination,
4148        source: PeerSource,
4149    ) -> Option<SocketAddr> {
4150        // Dedup: check if we already track this destination
4151        if self.i2p_destinations.values().any(|d| d == &dest) {
4152            return None;
4153        }
4154        let addr = self.next_i2p_synthetic_addr();
4155        self.i2p_destinations.insert(addr, dest);
4156        // M137: Track via unified PeerStates lifecycle
4157        if let Some(ref ps) = self.peer_states {
4158            ps.add_if_not_seen(addr, source);
4159        }
4160        Some(addr)
4161    }
4162
4163    /// Generate a unique synthetic `SocketAddr` for an I2P peer.
4164    ///
4165    /// Uses addresses from 240.0.0.0/4 (reserved, never routable) to avoid
4166    /// conflicts with real peers. The counter ensures uniqueness across the
4167    /// torrent's lifetime.
4168    fn next_i2p_synthetic_addr(&mut self) -> SocketAddr {
4169        self.i2p_peer_counter = self.i2p_peer_counter.wrapping_add(1);
4170        let a = ((self.i2p_peer_counter >> 16) & 0x0F) as u8 | 240;
4171        let b = ((self.i2p_peer_counter >> 8) & 0xFF) as u8;
4172        let c = (self.i2p_peer_counter & 0xFF) as u8;
4173        SocketAddr::new(
4174            std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, 1)),
4175            (self.i2p_peer_counter & 0xFFFF) as u16,
4176        )
4177    }
4178}
4179
4180/// Check whether a `SocketAddr` uses a synthetic I2P address (240.0.0.0/4 range).
4181pub(crate) fn is_i2p_synthetic_addr(addr: &SocketAddr) -> bool {
4182    match addr {
4183        SocketAddr::V4(v4) => v4.ip().octets()[0] & 0xF0 == 0xF0,
4184        _ => false,
4185    }
4186}
4187
4188/// Helper to accept a connection from an optional transport listener.
4189/// Returns `pending` if no listener is bound, so the `select!` branch is skipped.
4190async fn accept_incoming(
4191    listener: &mut Option<Box<dyn crate::transport::TransportListener>>,
4192) -> std::io::Result<(crate::transport::BoxedStream, SocketAddr)> {
4193    match listener {
4194        Some(l) => l.accept().await,
4195        None => std::future::pending().await,
4196    }
4197}
4198
4199/// Helper to receive an incoming I2P connection from the accept loop channel.
4200/// Returns `pending` if I2P is not enabled, so the `select!` branch is skipped.
4201async fn accept_i2p(
4202    rx: &mut Option<mpsc::Receiver<crate::i2p::SamStream>>,
4203) -> Option<crate::i2p::SamStream> {
4204    match rx {
4205        Some(rx) => rx.recv().await,
4206        None => std::future::pending().await,
4207    }
4208}
4209
4210// ============================================================================
4211// BEP 52 hash serving (M87)
4212// ============================================================================
4213
4214/// Determine what to serve for a BEP 52 hash request.
4215///
4216/// Returns `Some(hashes)` to serve, or `None` to reject.
4217/// Only serves piece-layer hashes (the layer stored in `piece_layers`).
4218/// Block-layer or other layer requests are rejected since we don't store
4219/// the full Merkle tree.
4220pub(crate) fn serve_hashes(
4221    meta_v2: Option<&irontide_core::TorrentMetaV2>,
4222    version: irontide_core::TorrentVersion,
4223    lengths: Option<&Lengths>,
4224    request: &irontide_core::HashRequest,
4225) -> Option<Vec<irontide_core::Id32>> {
4226    // Reject if v1-only or no v2 metadata
4227    let meta_v2 = match meta_v2 {
4228        Some(m) if version != irontide_core::TorrentVersion::V1Only => m,
4229        _ => return None,
4230    };
4231
4232    // Look up piece-layer hashes for the requested file root
4233    let piece_hashes = meta_v2.file_piece_hashes(&request.file_root)?;
4234
4235    // We need lengths to validate the request geometry
4236    let lengths = lengths?;
4237
4238    // Compute per-file block count from piece hashes and piece/chunk sizes.
4239    // Each piece hash covers `piece_length / chunk_size` blocks, except the
4240    // last piece which may cover fewer. For validation purposes we use the
4241    // padded count that `validate_hash_request` expects.
4242    let blocks_per_piece = (meta_v2.info.piece_length / lengths.chunk_size() as u64) as u32;
4243    let num_pieces = piece_hashes.len() as u32;
4244    let num_blocks = num_pieces.saturating_mul(blocks_per_piece);
4245
4246    if !irontide_core::validate_hash_request(request, num_blocks, num_pieces) {
4247        return None;
4248    }
4249
4250    // We only have piece-layer hashes. The piece layer is at
4251    // base = log2(blocks_per_piece). Reject requests for other layers.
4252    let piece_layer_base = blocks_per_piece.trailing_zeros();
4253    if request.base != piece_layer_base {
4254        return None;
4255    }
4256
4257    // Extract requested hashes from the piece layer
4258    let start = request.index as usize;
4259    let end = (start + request.count as usize).min(piece_hashes.len());
4260    let mut hashes: Vec<irontide_core::Id32> = piece_hashes[start..end].to_vec();
4261
4262    // Compute proof (uncle) hashes if requested.
4263    //
4264    // BEP 52 specifies a single subtree proof for the entire batch, not
4265    // per-leaf proofs. The receiver rebuilds the subtree root from the
4266    // base hashes itself, so we skip the first `log2(count)` levels of
4267    // the proof path (those are internal to the requested subtree) and
4268    // only send the uncle hashes above it.
4269    if request.proof_layers > 0 && !piece_hashes.is_empty() {
4270        let tree = irontide_core::MerkleTree::from_leaves(&piece_hashes);
4271        let full_proof = tree.proof_path(start);
4272        // Skip levels internal to the requested subtree
4273        let subtree_depth = if request.count > 1 {
4274            (request.count as usize)
4275                .next_power_of_two()
4276                .trailing_zeros() as usize
4277        } else {
4278            0
4279        };
4280        let available = full_proof.len().saturating_sub(subtree_depth);
4281        let proof_count = (request.proof_layers as usize).min(available);
4282        hashes.extend_from_slice(&full_proof[subtree_depth..subtree_depth + proof_count]);
4283    }
4284
4285    Some(hashes)
4286}
4287
4288// ============================================================================
4289// Tests
4290// ============================================================================
4291
4292#[cfg(test)]
4293mod tests {
4294    use super::*;
4295    use bytes::Bytes;
4296    use futures::{SinkExt, StreamExt};
4297    use irontide_wire::{ExtHandshake, Handshake, Message, MessageCodec};
4298    use std::time::Duration;
4299    use tokio::io::{AsyncReadExt, AsyncWriteExt};
4300    use tokio::net::TcpListener;
4301    use tokio_util::codec::{FramedRead, FramedWrite};
4302
4303    // -- Helpers --
4304
4305    /// Build a valid TorrentMetaV1 from raw data with given piece length.
4306    fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
4307        use serde::Serialize;
4308
4309        let mut pieces = Vec::new();
4310        let mut offset = 0;
4311        while offset < data.len() {
4312            let end = (offset + piece_length as usize).min(data.len());
4313            let hash = irontide_core::sha1(&data[offset..end]);
4314            pieces.extend_from_slice(hash.as_bytes());
4315            offset = end;
4316        }
4317
4318        #[derive(Serialize)]
4319        struct Info<'a> {
4320            length: u64,
4321            name: &'a str,
4322            #[serde(rename = "piece length")]
4323            piece_length: u64,
4324            #[serde(with = "serde_bytes")]
4325            pieces: &'a [u8],
4326        }
4327
4328        #[derive(Serialize)]
4329        struct Torrent<'a> {
4330            info: Info<'a>,
4331        }
4332
4333        let t = Torrent {
4334            info: Info {
4335                length: data.len() as u64,
4336                name: "test",
4337                piece_length,
4338                pieces: &pieces,
4339            },
4340        };
4341
4342        let bytes = irontide_bencode::to_bytes(&t).unwrap();
4343        torrent_from_bytes(&bytes).unwrap()
4344    }
4345
4346    fn test_config() -> TorrentConfig {
4347        TorrentConfig {
4348            listen_port: 0, // random port
4349            max_peers: 200,
4350            target_request_queue: 5,
4351            download_dir: std::path::PathBuf::from("/tmp"),
4352            enable_dht: false,
4353            enable_pex: false,
4354            enable_fast: false,
4355            seed_ratio_limit: None,
4356            strict_end_game: true,
4357            upload_rate_limit: 0,
4358            download_rate_limit: 0,
4359            encryption_mode: irontide_wire::mse::EncryptionMode::Disabled,
4360            enable_utp: false,
4361            enable_web_seed: true,
4362            enable_holepunch: false,
4363            enable_bep40_eviction: true,
4364            max_web_seeds: 4,
4365            super_seeding: false,
4366            upload_only_announce: true,
4367            hashing_threads: 2,
4368            sequential_download: false,
4369            initial_picker_threshold: 4,
4370            whole_pieces_threshold: 20,
4371            snub_timeout_secs: 15,
4372            readahead_pieces: 8,
4373            streaming_timeout_escalation: true,
4374            max_concurrent_stream_reads: 8,
4375            proxy: crate::proxy::ProxyConfig::default(),
4376            anonymous_mode: false,
4377            share_mode: false,
4378            enable_i2p: false,
4379            allow_i2p_mixed: false,
4380            ssl_listen_port: 0,
4381            seed_choking_algorithm: crate::choker::SeedChokingAlgorithm::FastestUpload,
4382            choking_algorithm: crate::choker::ChokingAlgorithm::FixedSlots,
4383            piece_extent_affinity: true,
4384            suggest_mode: false,
4385            max_suggest_pieces: 10,
4386            predictive_piece_announce_ms: 0,
4387            mixed_mode_algorithm: crate::rate_limiter::MixedModeAlgorithm::PeerProportional,
4388            auto_sequential: true,
4389            storage_mode: irontide_core::StorageMode::Auto,
4390            preallocate_mode: None,
4391            block_request_timeout_secs: 60,
4392            enable_lsd: false,
4393            force_proxy: false,
4394            steal_threshold_ratio: 10.0,
4395            steal_threshold_endgame: 3.0,
4396            min_pipeline_depth: 16,
4397            max_pipeline_depth: 512,
4398            target_buffer_secs: 2.0,
4399            peer_read_timeout_secs: 0,         // disabled in tests
4400            peer_write_timeout_secs: 0,        // disabled in tests
4401            data_contribution_timeout_secs: 0, // disabled in tests
4402            choke_rotation_max_evictions: 0,   // disabled in tests
4403            max_concurrent_connects: 128,
4404            connect_soft_timeout: 3,
4405            url_security: crate::url_guard::UrlSecurityConfig::default(),
4406            peer_connect_timeout: 2,
4407            peer_dscp: 0x08,
4408            initial_queue_depth: 128,
4409            max_request_queue_depth: 250,
4410            request_queue_time: 3.0,
4411            max_metadata_size: 4 * 1024 * 1024,
4412            max_message_size: 16 * 1024 * 1024,
4413            max_piece_length: 32 * 1024 * 1024,
4414            max_outstanding_requests: 500,
4415            max_in_flight_pieces: 20,
4416            use_block_stealing: true,
4417            steal_stale_piece_secs: 2,
4418            fixed_pipeline_depth: 128,
4419            lock_warn_threshold_ms: 0, // disabled in tests
4420            filesystem_direct_io: false,
4421        }
4422    }
4423
4424    fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
4425        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
4426        Arc::new(MemoryStorage::new(lengths))
4427    }
4428
4429    fn make_seeded_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
4430        let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
4431        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
4432        // Write data piece by piece
4433        let num_pieces = lengths.num_pieces();
4434        for p in 0..num_pieces {
4435            let piece_size = lengths.piece_size(p) as usize;
4436            let offset = lengths.piece_offset(p) as usize;
4437            let end = offset + piece_size;
4438            storage.write_chunk(p, 0, &data[offset..end]).unwrap();
4439        }
4440        storage
4441    }
4442
4443    fn test_alert_channel() -> (broadcast::Sender<Alert>, Arc<AtomicU32>) {
4444        let (tx, _) = broadcast::channel(64);
4445        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
4446        (tx, mask)
4447    }
4448
4449    fn test_ban_manager() -> crate::session::SharedBanManager {
4450        Arc::new(parking_lot::RwLock::new(crate::ban::BanManager::new(
4451            crate::ban::BanConfig::default(),
4452        )))
4453    }
4454
4455    fn test_ip_filter() -> crate::session::SharedIpFilter {
4456        Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()))
4457    }
4458
4459    fn test_disk_manager() -> (DiskManagerHandle, tokio::task::JoinHandle<()>) {
4460        DiskManagerHandle::new(crate::disk::DiskConfig::default())
4461    }
4462
4463    async fn test_register_disk(
4464        info_hash: Id20,
4465        storage: Arc<dyn TorrentStorage>,
4466    ) -> (DiskHandle, DiskManagerHandle, tokio::task::JoinHandle<()>) {
4467        let (dm, join) = test_disk_manager();
4468        let dh = dm.register_torrent(info_hash, storage).await;
4469        (dh, dm, join)
4470    }
4471
4472    /// Handshake size constant.
4473    const HANDSHAKE_SIZE: usize = 68;
4474
4475    // ---- Test 1: Create from torrent ----
4476
4477    #[tokio::test]
4478    async fn create_from_torrent() {
4479        let data = vec![0xAB; 32768]; // 32 KiB
4480        let meta = make_test_torrent(&data, 16384); // 2 pieces
4481        let storage = make_storage(&data, 16384);
4482        let config = test_config();
4483
4484        let (atx, amask) = test_alert_channel();
4485        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4486        let handle = TorrentHandle::from_torrent(
4487            meta,
4488            irontide_core::TorrentVersion::V1Only,
4489            None,
4490            dh,
4491            dm,
4492            config,
4493            None,
4494            None,
4495            None,
4496            None,
4497            crate::slot_tuner::SlotTuner::disabled(4),
4498            atx,
4499            amask,
4500            None,
4501            None,
4502            test_ban_manager(),
4503            test_ip_filter(),
4504            Arc::new(Vec::new()),
4505            None,
4506            None,
4507            Arc::new(crate::transport::NetworkFactory::tokio()),
4508            None, // M96: hash_pool
4509        )
4510        .await
4511        .unwrap();
4512
4513        let stats = handle.stats().await.unwrap();
4514        assert_eq!(stats.state, TorrentState::Downloading);
4515        assert_eq!(stats.pieces_total, 2);
4516        assert_eq!(stats.pieces_have, 0);
4517        assert_eq!(stats.peers_connected, 0);
4518
4519        handle.shutdown().await.unwrap();
4520    }
4521
4522    // ---- Test 2: Create from magnet ----
4523
4524    #[tokio::test]
4525    async fn create_from_magnet() {
4526        let magnet = Magnet {
4527            info_hashes: irontide_core::InfoHashes::v1_only(
4528                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
4529            ),
4530            display_name: Some("test".into()),
4531            trackers: vec![],
4532            peers: vec![],
4533            selected_files: None,
4534        };
4535        let config = test_config();
4536
4537        let (atx, amask) = test_alert_channel();
4538        let (dm, _dj) = test_disk_manager();
4539        let handle = TorrentHandle::from_magnet(
4540            magnet,
4541            dm,
4542            config,
4543            None,
4544            None,
4545            None,
4546            None,
4547            crate::slot_tuner::SlotTuner::disabled(4),
4548            atx,
4549            amask,
4550            None,
4551            None,
4552            test_ban_manager(),
4553            test_ip_filter(),
4554            Arc::new(Vec::new()),
4555            None,
4556            None,
4557            Arc::new(crate::transport::NetworkFactory::tokio()),
4558            None, // M96: hash_pool
4559        )
4560        .await
4561        .unwrap();
4562
4563        let stats = handle.stats().await.unwrap();
4564        assert_eq!(stats.state, TorrentState::FetchingMetadata);
4565        assert_eq!(stats.pieces_total, 0);
4566
4567        handle.shutdown().await.unwrap();
4568    }
4569
4570    // ---- Test 3: Add peers ----
4571
4572    #[tokio::test]
4573    async fn add_peers_increases_available() {
4574        let data = vec![0xAB; 32768];
4575        let meta = make_test_torrent(&data, 16384);
4576        let storage = make_storage(&data, 16384);
4577        let config = test_config();
4578
4579        let (atx, amask) = test_alert_channel();
4580        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4581        let handle = TorrentHandle::from_torrent(
4582            meta,
4583            irontide_core::TorrentVersion::V1Only,
4584            None,
4585            dh,
4586            dm,
4587            config,
4588            None,
4589            None,
4590            None,
4591            None,
4592            crate::slot_tuner::SlotTuner::disabled(4),
4593            atx,
4594            amask,
4595            None,
4596            None,
4597            test_ban_manager(),
4598            test_ip_filter(),
4599            Arc::new(Vec::new()),
4600            None,
4601            None,
4602            Arc::new(crate::transport::NetworkFactory::tokio()),
4603            None, // M96: hash_pool
4604        )
4605        .await
4606        .unwrap();
4607
4608        // Bind listeners so the connect attempts succeed and peers stay in connected state
4609        let _listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
4610        let addr1 = _listener1.local_addr().unwrap();
4611        let _listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
4612        let addr2 = _listener2.local_addr().unwrap();
4613
4614        handle
4615            .add_peers(vec![addr1, addr2], PeerSource::Tracker)
4616            .await
4617            .unwrap();
4618
4619        // Small delay for the actor to process
4620        tokio::time::sleep(Duration::from_millis(100)).await;
4621
4622        let stats = handle.stats().await.unwrap();
4623        // Peers may be available or already connecting (try_connect_peers fires immediately)
4624        assert!(
4625            stats.peers_available + stats.peers_connected >= 2,
4626            "expected at least 2 peers known, got available={}, connected={}",
4627            stats.peers_available,
4628            stats.peers_connected
4629        );
4630
4631        handle.shutdown().await.unwrap();
4632    }
4633
4634    // ---- Test 4: Stats reporting ----
4635
4636    #[tokio::test]
4637    async fn stats_reporting() {
4638        let data = vec![0xAB; 65536]; // 64 KiB
4639        let meta = make_test_torrent(&data, 16384); // 4 pieces
4640        let storage = make_storage(&data, 16384);
4641        let config = test_config();
4642
4643        let (atx, amask) = test_alert_channel();
4644        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4645        let handle = TorrentHandle::from_torrent(
4646            meta,
4647            irontide_core::TorrentVersion::V1Only,
4648            None,
4649            dh,
4650            dm,
4651            config,
4652            None,
4653            None,
4654            None,
4655            None,
4656            crate::slot_tuner::SlotTuner::disabled(4),
4657            atx,
4658            amask,
4659            None,
4660            None,
4661            test_ban_manager(),
4662            test_ip_filter(),
4663            Arc::new(Vec::new()),
4664            None,
4665            None,
4666            Arc::new(crate::transport::NetworkFactory::tokio()),
4667            None, // M96: hash_pool
4668        )
4669        .await
4670        .unwrap();
4671
4672        let stats = handle.stats().await.unwrap();
4673        assert_eq!(stats.state, TorrentState::Downloading);
4674        assert_eq!(stats.downloaded, 0);
4675        assert_eq!(stats.uploaded, 0);
4676        assert_eq!(stats.pieces_have, 0);
4677        assert_eq!(stats.pieces_total, 4);
4678        assert_eq!(stats.peers_connected, 0);
4679        assert_eq!(stats.peers_available, 0);
4680
4681        handle.shutdown().await.unwrap();
4682    }
4683
4684    // ---- Test 5: Private torrent disables DHT/PEX ----
4685
4686    #[tokio::test]
4687    async fn private_torrent_disables_dht_pex() {
4688        // Build a private torrent by embedding private=1 in the info dict
4689        use serde::Serialize;
4690
4691        let data = vec![0xAB; 16384];
4692        let hash = irontide_core::sha1(&data);
4693        let mut pieces = Vec::new();
4694        pieces.extend_from_slice(hash.as_bytes());
4695
4696        #[derive(Serialize)]
4697        struct Info<'a> {
4698            length: u64,
4699            name: &'a str,
4700            #[serde(rename = "piece length")]
4701            piece_length: u64,
4702            #[serde(with = "serde_bytes")]
4703            pieces: &'a [u8],
4704            private: i64,
4705        }
4706
4707        #[derive(Serialize)]
4708        struct Torrent<'a> {
4709            info: Info<'a>,
4710        }
4711
4712        let t = Torrent {
4713            info: Info {
4714                length: data.len() as u64,
4715                name: "private_test",
4716                piece_length: 16384,
4717                pieces: &pieces,
4718                private: 1,
4719            },
4720        };
4721
4722        let bytes = irontide_bencode::to_bytes(&t).unwrap();
4723        let meta = torrent_from_bytes(&bytes).unwrap();
4724        assert_eq!(meta.info.private, Some(1));
4725
4726        let storage = make_storage(&data, 16384);
4727        let mut config = test_config();
4728        config.enable_dht = true;
4729        config.enable_pex = true;
4730
4731        // The from_torrent constructor should disable DHT and PEX
4732        let (atx, amask) = test_alert_channel();
4733        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4734        let handle = TorrentHandle::from_torrent(
4735            meta,
4736            irontide_core::TorrentVersion::V1Only,
4737            None,
4738            dh,
4739            dm,
4740            config,
4741            None,
4742            None,
4743            None,
4744            None,
4745            crate::slot_tuner::SlotTuner::disabled(4),
4746            atx,
4747            amask,
4748            None,
4749            None,
4750            test_ban_manager(),
4751            test_ip_filter(),
4752            Arc::new(Vec::new()),
4753            None,
4754            None,
4755            Arc::new(crate::transport::NetworkFactory::tokio()),
4756            None, // M96: hash_pool
4757        )
4758        .await
4759        .unwrap();
4760
4761        // We can't directly inspect the actor's config, but we can verify
4762        // the torrent was created successfully. The real test is that PEX peers
4763        // would be ignored and DHT not used. For now verify the handle works.
4764        let stats = handle.stats().await.unwrap();
4765        assert_eq!(stats.state, TorrentState::Downloading);
4766
4767        handle.shutdown().await.unwrap();
4768    }
4769
4770    // ---- Test 6: Shutdown cleanup ----
4771
4772    #[tokio::test]
4773    async fn shutdown_cleanup() {
4774        let data = vec![0xAB; 16384];
4775        let meta = make_test_torrent(&data, 16384);
4776        let storage = make_storage(&data, 16384);
4777        let config = test_config();
4778
4779        let (atx, amask) = test_alert_channel();
4780        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4781        let handle = TorrentHandle::from_torrent(
4782            meta,
4783            irontide_core::TorrentVersion::V1Only,
4784            None,
4785            dh,
4786            dm,
4787            config,
4788            None,
4789            None,
4790            None,
4791            None,
4792            crate::slot_tuner::SlotTuner::disabled(4),
4793            atx,
4794            amask,
4795            None,
4796            None,
4797            test_ban_manager(),
4798            test_ip_filter(),
4799            Arc::new(Vec::new()),
4800            None,
4801            None,
4802            Arc::new(crate::transport::NetworkFactory::tokio()),
4803            None, // M96: hash_pool
4804        )
4805        .await
4806        .unwrap();
4807
4808        handle.shutdown().await.unwrap();
4809
4810        // After shutdown, stats should fail (channel closed)
4811        tokio::time::sleep(Duration::from_millis(50)).await;
4812        let result = handle.stats().await;
4813        assert!(result.is_err());
4814    }
4815
4816    // ---- Test 7: Duplicate add_peers ignored ----
4817
4818    #[tokio::test]
4819    async fn duplicate_peers_ignored() {
4820        let data = vec![0xAB; 16384];
4821        let meta = make_test_torrent(&data, 16384);
4822        let storage = make_storage(&data, 16384);
4823        let config = test_config();
4824
4825        let (atx, amask) = test_alert_channel();
4826        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4827        let handle = TorrentHandle::from_torrent(
4828            meta,
4829            irontide_core::TorrentVersion::V1Only,
4830            None,
4831            dh,
4832            dm,
4833            config,
4834            None,
4835            None,
4836            None,
4837            None,
4838            crate::slot_tuner::SlotTuner::disabled(4),
4839            atx,
4840            amask,
4841            None,
4842            None,
4843            test_ban_manager(),
4844            test_ip_filter(),
4845            Arc::new(Vec::new()),
4846            None,
4847            None,
4848            Arc::new(crate::transport::NetworkFactory::tokio()),
4849            None, // M96: hash_pool
4850        )
4851        .await
4852        .unwrap();
4853
4854        // Bind a listener so the connection succeeds and the peer stays connected
4855        let _listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4856        let addr = _listener.local_addr().unwrap();
4857        handle
4858            .add_peers(vec![addr, addr, addr], PeerSource::Tracker)
4859            .await
4860            .unwrap();
4861
4862        tokio::time::sleep(Duration::from_millis(100)).await;
4863        let stats = handle.stats().await.unwrap();
4864        // Only one unique peer should be known (available or connecting)
4865        assert!(
4866            stats.peers_available + stats.peers_connected <= 1,
4867            "expected at most 1 unique peer, got available={}, connected={}",
4868            stats.peers_available,
4869            stats.peers_connected
4870        );
4871
4872        handle.shutdown().await.unwrap();
4873    }
4874
4875    // ---- Test 8: Multiple handles (Clone) share same actor ----
4876
4877    #[tokio::test]
4878    async fn cloned_handle_shares_actor() {
4879        let data = vec![0xAB; 16384];
4880        let meta = make_test_torrent(&data, 16384);
4881        let storage = make_storage(&data, 16384);
4882        let config = test_config();
4883
4884        let (atx, amask) = test_alert_channel();
4885        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4886        let handle = TorrentHandle::from_torrent(
4887            meta,
4888            irontide_core::TorrentVersion::V1Only,
4889            None,
4890            dh,
4891            dm,
4892            config,
4893            None,
4894            None,
4895            None,
4896            None,
4897            crate::slot_tuner::SlotTuner::disabled(4),
4898            atx,
4899            amask,
4900            None,
4901            None,
4902            test_ban_manager(),
4903            test_ip_filter(),
4904            Arc::new(Vec::new()),
4905            None,
4906            None,
4907            Arc::new(crate::transport::NetworkFactory::tokio()),
4908            None, // M96: hash_pool
4909        )
4910        .await
4911        .unwrap();
4912        let handle2 = handle.clone();
4913
4914        // Bind a listener so the connection succeeds and the peer stays connected
4915        let _listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4916        let peer_addr = _listener.local_addr().unwrap();
4917
4918        // Add peers through one handle
4919        handle
4920            .add_peers(vec![peer_addr], PeerSource::Tracker)
4921            .await
4922            .unwrap();
4923
4924        tokio::time::sleep(Duration::from_millis(100)).await;
4925
4926        // Read stats through the other — peer may be available or connecting
4927        let stats = handle2.stats().await.unwrap();
4928        assert!(
4929            stats.peers_available + stats.peers_connected >= 1,
4930            "expected at least 1 peer known, got available={}, connected={}",
4931            stats.peers_available,
4932            stats.peers_connected
4933        );
4934
4935        handle.shutdown().await.unwrap();
4936    }
4937
4938    // ---- Test 9: Peer connection and disconnect via listener ----
4939
4940    #[tokio::test]
4941    async fn peer_connect_and_disconnect_via_listener() {
4942        let data = vec![0xAB; 16384];
4943        let meta = make_test_torrent(&data, 16384);
4944        let info_hash = meta.info_hash;
4945        let storage = make_storage(&data, 16384);
4946
4947        // Bind a listener on a specific port so we can connect to it
4948        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4949        let listen_addr = listener.local_addr().unwrap();
4950
4951        let config = TorrentConfig {
4952            listen_port: listen_addr.port(),
4953            ..test_config()
4954        };
4955
4956        // Drop the pre-bound listener before from_torrent binds
4957        drop(listener);
4958
4959        let (atx, amask) = test_alert_channel();
4960        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
4961        let handle = TorrentHandle::from_torrent(
4962            meta,
4963            irontide_core::TorrentVersion::V1Only,
4964            None,
4965            dh,
4966            dm,
4967            config,
4968            None,
4969            None,
4970            None,
4971            None,
4972            crate::slot_tuner::SlotTuner::disabled(4),
4973            atx,
4974            amask,
4975            None,
4976            None,
4977            test_ban_manager(),
4978            test_ip_filter(),
4979            Arc::new(Vec::new()),
4980            None,
4981            None,
4982            Arc::new(crate::transport::NetworkFactory::tokio()),
4983            None, // M96: hash_pool
4984        )
4985        .await
4986        .unwrap();
4987
4988        // Give the actor time to start
4989        tokio::time::sleep(Duration::from_millis(50)).await;
4990
4991        // Connect a mock peer
4992        let mut stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
4993
4994        // Perform handshake
4995        let remote_id = Id20::from_hex("1111111111111111111111111111111111111111").unwrap();
4996        let remote_hs = Handshake::new(info_hash, remote_id);
4997        stream.write_all(&remote_hs.to_bytes()).await.unwrap();
4998        stream.flush().await.unwrap();
4999
5000        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
5001        stream.read_exact(&mut hs_buf).await.unwrap();
5002        let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
5003        assert_eq!(their_hs.info_hash, info_hash);
5004
5005        // Give time for peer to be registered
5006        tokio::time::sleep(Duration::from_millis(100)).await;
5007
5008        let stats = handle.stats().await.unwrap();
5009        assert_eq!(stats.peers_connected, 1);
5010
5011        // Drop the connection
5012        drop(stream);
5013
5014        // Wait for disconnect event
5015        tokio::time::sleep(Duration::from_millis(200)).await;
5016
5017        let stats = handle.stats().await.unwrap();
5018        assert_eq!(stats.peers_connected, 0);
5019
5020        handle.shutdown().await.unwrap();
5021    }
5022
5023    // ---- Test 10: Piece download and verification via injected events ----
5024    //
5025    // We test the full flow: connect a mock peer that sends bitfield, unchoke,
5026    // then responds to requests with correct piece data.
5027
5028    #[tokio::test]
5029    async fn piece_download_and_verify() {
5030        // Create a 1-piece torrent with 16384 bytes (exactly one chunk)
5031        let data = vec![0xCDu8; 16384];
5032        let meta = make_test_torrent(&data, 16384);
5033        let info_hash = meta.info_hash;
5034        let storage = make_storage(&data, 16384);
5035
5036        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5037        let listen_addr = listener.local_addr().unwrap();
5038        drop(listener);
5039
5040        let config = TorrentConfig {
5041            listen_port: listen_addr.port(),
5042            ..test_config()
5043        };
5044
5045        let (atx, amask) = test_alert_channel();
5046        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5047        let handle = TorrentHandle::from_torrent(
5048            meta,
5049            irontide_core::TorrentVersion::V1Only,
5050            None,
5051            dh,
5052            dm,
5053            config,
5054            None,
5055            None,
5056            None,
5057            None,
5058            crate::slot_tuner::SlotTuner::disabled(4),
5059            atx,
5060            amask,
5061            None,
5062            None,
5063            test_ban_manager(),
5064            test_ip_filter(),
5065            Arc::new(Vec::new()),
5066            None,
5067            None,
5068            Arc::new(crate::transport::NetworkFactory::tokio()),
5069            None, // M96: hash_pool
5070        )
5071        .await
5072        .unwrap();
5073
5074        tokio::time::sleep(Duration::from_millis(50)).await;
5075
5076        // Connect mock peer
5077        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
5078        let remote_id = Id20::from_hex("2222222222222222222222222222222222222222").unwrap();
5079
5080        // Run mock seeder in a task
5081        let mock_data = data.clone();
5082        let mock_task = tokio::spawn(async move {
5083            let (reader, writer) = tokio::io::split(stream);
5084            let mut reader = reader;
5085            let mut writer = writer;
5086
5087            // Handshake
5088            let hs = Handshake::new(info_hash, remote_id);
5089            writer.write_all(&hs.to_bytes()).await.unwrap();
5090            writer.flush().await.unwrap();
5091
5092            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
5093            reader.read_exact(&mut hs_buf).await.unwrap();
5094
5095            // Switch to framed
5096            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
5097            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
5098
5099            // Read ext handshake from the torrent actor's peer
5100            let _msg = framed_read.next().await;
5101
5102            // Send ext handshake back
5103            let ext_hs = ExtHandshake::new();
5104            let payload = ext_hs.to_bytes().unwrap();
5105            framed_write
5106                .send(Message::Extended { ext_id: 0, payload })
5107                .await
5108                .unwrap();
5109
5110            // Send bitfield (all pieces = piece 0 set)
5111            let mut bf = Bitfield::new(1);
5112            bf.set(0);
5113            framed_write
5114                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
5115                .await
5116                .unwrap();
5117
5118            // Send Unchoke
5119            framed_write.send(Message::Unchoke).await.unwrap();
5120
5121            // Wait for requests and respond with piece data
5122            while let Some(Ok(msg)) = framed_read.next().await {
5123                match msg {
5124                    Message::Request {
5125                        index,
5126                        begin,
5127                        length,
5128                    } => {
5129                        let start = begin as usize;
5130                        let end = start + length as usize;
5131                        let piece_data = &mock_data[start..end];
5132                        framed_write
5133                            .send(Message::Piece {
5134                                index,
5135                                begin,
5136                                data_0: Bytes::copy_from_slice(piece_data),
5137                                data_1: Bytes::new(),
5138                            })
5139                            .await
5140                            .unwrap();
5141                    }
5142                    Message::Interested => {
5143                        // Expected — the torrent should express interest
5144                    }
5145                    _ => {}
5146                }
5147            }
5148        });
5149
5150        // Wait for the download to complete
5151        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
5152        loop {
5153            tokio::time::sleep(Duration::from_millis(100)).await;
5154            let stats = handle.stats().await.unwrap();
5155            if stats.state == TorrentState::Seeding {
5156                assert_eq!(stats.pieces_have, 1);
5157                assert_eq!(stats.pieces_total, 1);
5158                break;
5159            }
5160            if tokio::time::Instant::now() > deadline {
5161                let stats = handle.stats().await.unwrap();
5162                panic!(
5163                    "download did not complete within 5s, state={:?}, have={}/{}",
5164                    stats.state, stats.pieces_have, stats.pieces_total
5165                );
5166            }
5167        }
5168
5169        handle.shutdown().await.unwrap();
5170        mock_task.abort();
5171    }
5172
5173    // ---- Test 11: Failed piece verification re-requests ----
5174
5175    #[tokio::test]
5176    async fn failed_piece_verification() {
5177        // Create a 1-piece torrent
5178        let data = vec![0xEEu8; 16384];
5179        let meta = make_test_torrent(&data, 16384);
5180        let info_hash = meta.info_hash;
5181        let storage = make_storage(&data, 16384);
5182
5183        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5184        let listen_addr = listener.local_addr().unwrap();
5185        drop(listener);
5186
5187        let config = TorrentConfig {
5188            listen_port: listen_addr.port(),
5189            ..test_config()
5190        };
5191
5192        let (atx, amask) = test_alert_channel();
5193        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5194        let handle = TorrentHandle::from_torrent(
5195            meta,
5196            irontide_core::TorrentVersion::V1Only,
5197            None,
5198            dh,
5199            dm,
5200            config,
5201            None,
5202            None,
5203            None,
5204            None,
5205            crate::slot_tuner::SlotTuner::disabled(4),
5206            atx,
5207            amask,
5208            None,
5209            None,
5210            test_ban_manager(),
5211            test_ip_filter(),
5212            Arc::new(Vec::new()),
5213            None,
5214            None,
5215            Arc::new(crate::transport::NetworkFactory::tokio()),
5216            None, // M96: hash_pool
5217        )
5218        .await
5219        .unwrap();
5220
5221        tokio::time::sleep(Duration::from_millis(50)).await;
5222
5223        // Connect mock peer that first sends bad data, then correct data
5224        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
5225        let remote_id = Id20::from_hex("3333333333333333333333333333333333333333").unwrap();
5226
5227        let correct_data = data.clone();
5228        let mock_task = tokio::spawn(async move {
5229            let (reader, writer) = tokio::io::split(stream);
5230
5231            // Handshake
5232            let mut writer = writer;
5233            let mut reader = reader;
5234            let hs = Handshake::new(info_hash, remote_id);
5235            writer.write_all(&hs.to_bytes()).await.unwrap();
5236            writer.flush().await.unwrap();
5237
5238            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
5239            reader.read_exact(&mut hs_buf).await.unwrap();
5240
5241            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
5242            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
5243
5244            // Read ext handshake
5245            let _msg = framed_read.next().await;
5246
5247            // Send ext handshake
5248            let ext_hs = ExtHandshake::new();
5249            let payload = ext_hs.to_bytes().unwrap();
5250            framed_write
5251                .send(Message::Extended { ext_id: 0, payload })
5252                .await
5253                .unwrap();
5254
5255            // Bitfield: have piece 0
5256            let mut bf = Bitfield::new(1);
5257            bf.set(0);
5258            framed_write
5259                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
5260                .await
5261                .unwrap();
5262
5263            // Unchoke
5264            framed_write.send(Message::Unchoke).await.unwrap();
5265
5266            let mut request_count = 0u32;
5267            while let Some(Ok(msg)) = framed_read.next().await {
5268                match msg {
5269                    Message::Request {
5270                        index,
5271                        begin,
5272                        length,
5273                    } => {
5274                        request_count += 1;
5275                        let piece_data = if request_count <= 1 {
5276                            // First request: send bad data
5277                            vec![0xFF; length as usize]
5278                        } else {
5279                            // Subsequent: send correct data
5280                            let start = begin as usize;
5281                            let end = start + length as usize;
5282                            correct_data[start..end].to_vec()
5283                        };
5284                        framed_write
5285                            .send(Message::Piece {
5286                                index,
5287                                begin,
5288                                data_0: Bytes::from(piece_data),
5289                                data_1: Bytes::new(),
5290                            })
5291                            .await
5292                            .unwrap();
5293                    }
5294                    Message::Interested => {}
5295                    _ => {}
5296                }
5297            }
5298        });
5299
5300        // Wait for completion (should eventually succeed after retry)
5301        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
5302        loop {
5303            tokio::time::sleep(Duration::from_millis(100)).await;
5304            let stats = handle.stats().await.unwrap();
5305            if stats.state == TorrentState::Seeding {
5306                assert_eq!(stats.pieces_have, 1);
5307                break;
5308            }
5309            if tokio::time::Instant::now() > deadline {
5310                let stats = handle.stats().await.unwrap();
5311                panic!(
5312                    "download did not complete after retry within 5s, state={:?}, have={}",
5313                    stats.state, stats.pieces_have,
5314                );
5315            }
5316        }
5317
5318        handle.shutdown().await.unwrap();
5319        mock_task.abort();
5320    }
5321
5322    // ---- Test 12: Complete state transitions after all pieces ----
5323
5324    #[tokio::test]
5325    async fn complete_transitions_state() {
5326        // 2-piece torrent, each 16384 bytes (one chunk each)
5327        let data = vec![0xBBu8; 32768];
5328        let meta = make_test_torrent(&data, 16384);
5329        let info_hash = meta.info_hash;
5330        let storage = make_storage(&data, 16384);
5331
5332        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5333        let listen_addr = listener.local_addr().unwrap();
5334        drop(listener);
5335
5336        let config = TorrentConfig {
5337            listen_port: listen_addr.port(),
5338            ..test_config()
5339        };
5340
5341        let (atx, amask) = test_alert_channel();
5342        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5343        let handle = TorrentHandle::from_torrent(
5344            meta,
5345            irontide_core::TorrentVersion::V1Only,
5346            None,
5347            dh,
5348            dm,
5349            config,
5350            None,
5351            None,
5352            None,
5353            None,
5354            crate::slot_tuner::SlotTuner::disabled(4),
5355            atx,
5356            amask,
5357            None,
5358            None,
5359            test_ban_manager(),
5360            test_ip_filter(),
5361            Arc::new(Vec::new()),
5362            None,
5363            None,
5364            Arc::new(crate::transport::NetworkFactory::tokio()),
5365            None, // M96: hash_pool
5366        )
5367        .await
5368        .unwrap();
5369
5370        tokio::time::sleep(Duration::from_millis(50)).await;
5371
5372        // Mock seeder with all 2 pieces
5373        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
5374        let remote_id = Id20::from_hex("4444444444444444444444444444444444444444").unwrap();
5375
5376        let mock_data = data.clone();
5377        let mock_task = tokio::spawn(async move {
5378            let (reader, writer) = tokio::io::split(stream);
5379            let mut writer = writer;
5380            let mut reader = reader;
5381
5382            let hs = Handshake::new(info_hash, remote_id);
5383            writer.write_all(&hs.to_bytes()).await.unwrap();
5384            writer.flush().await.unwrap();
5385
5386            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
5387            reader.read_exact(&mut hs_buf).await.unwrap();
5388
5389            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
5390            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
5391
5392            // Read ext handshake
5393            let _msg = framed_read.next().await;
5394
5395            // Send ext handshake
5396            let ext_hs = ExtHandshake::new();
5397            let payload = ext_hs.to_bytes().unwrap();
5398            framed_write
5399                .send(Message::Extended { ext_id: 0, payload })
5400                .await
5401                .unwrap();
5402
5403            // Bitfield: have both pieces
5404            let mut bf = Bitfield::new(2);
5405            bf.set(0);
5406            bf.set(1);
5407            framed_write
5408                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
5409                .await
5410                .unwrap();
5411
5412            framed_write.send(Message::Unchoke).await.unwrap();
5413
5414            while let Some(Ok(msg)) = framed_read.next().await {
5415                match msg {
5416                    Message::Request {
5417                        index,
5418                        begin,
5419                        length,
5420                    } => {
5421                        let abs_start = (index as usize * 16384) + begin as usize;
5422                        let abs_end = abs_start + length as usize;
5423                        let piece_data = &mock_data[abs_start..abs_end];
5424                        framed_write
5425                            .send(Message::Piece {
5426                                index,
5427                                begin,
5428                                data_0: Bytes::copy_from_slice(piece_data),
5429                                data_1: Bytes::new(),
5430                            })
5431                            .await
5432                            .unwrap();
5433                    }
5434                    Message::Interested => {}
5435                    _ => {}
5436                }
5437            }
5438        });
5439
5440        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
5441        loop {
5442            tokio::time::sleep(Duration::from_millis(100)).await;
5443            let stats = handle.stats().await.unwrap();
5444            if stats.state == TorrentState::Seeding {
5445                assert_eq!(stats.pieces_have, 2);
5446                assert_eq!(stats.pieces_total, 2);
5447                break;
5448            }
5449            if tokio::time::Instant::now() > deadline {
5450                let stats = handle.stats().await.unwrap();
5451                panic!(
5452                    "expected Complete, got {:?}, have={}/{}",
5453                    stats.state, stats.pieces_have, stats.pieces_total
5454                );
5455            }
5456        }
5457
5458        handle.shutdown().await.unwrap();
5459        mock_task.abort();
5460    }
5461
5462    // ---- Test 13: Multiple pieces with multi-chunk pieces ----
5463
5464    #[tokio::test]
5465    async fn multi_chunk_piece_download() {
5466        // 1 piece of 32768 bytes = 2 chunks of 16384 each
5467        let data = vec![0xAAu8; 32768];
5468        let meta = make_test_torrent(&data, 32768);
5469        let info_hash = meta.info_hash;
5470        let storage = make_storage(&data, 32768);
5471
5472        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5473        let listen_addr = listener.local_addr().unwrap();
5474        drop(listener);
5475
5476        let config = TorrentConfig {
5477            listen_port: listen_addr.port(),
5478            ..test_config()
5479        };
5480
5481        let (atx, amask) = test_alert_channel();
5482        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5483        let handle = TorrentHandle::from_torrent(
5484            meta,
5485            irontide_core::TorrentVersion::V1Only,
5486            None,
5487            dh,
5488            dm,
5489            config,
5490            None,
5491            None,
5492            None,
5493            None,
5494            crate::slot_tuner::SlotTuner::disabled(4),
5495            atx,
5496            amask,
5497            None,
5498            None,
5499            test_ban_manager(),
5500            test_ip_filter(),
5501            Arc::new(Vec::new()),
5502            None,
5503            None,
5504            Arc::new(crate::transport::NetworkFactory::tokio()),
5505            None, // M96: hash_pool
5506        )
5507        .await
5508        .unwrap();
5509
5510        tokio::time::sleep(Duration::from_millis(50)).await;
5511
5512        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
5513        let remote_id = Id20::from_hex("5555555555555555555555555555555555555555").unwrap();
5514
5515        let mock_data = data.clone();
5516        let mock_task = tokio::spawn(async move {
5517            let (reader, writer) = tokio::io::split(stream);
5518            let mut writer = writer;
5519            let mut reader = reader;
5520
5521            let hs = Handshake::new(info_hash, remote_id);
5522            writer.write_all(&hs.to_bytes()).await.unwrap();
5523            writer.flush().await.unwrap();
5524
5525            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
5526            reader.read_exact(&mut hs_buf).await.unwrap();
5527
5528            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
5529            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
5530
5531            let _msg = framed_read.next().await;
5532
5533            let ext_hs = ExtHandshake::new();
5534            let payload = ext_hs.to_bytes().unwrap();
5535            framed_write
5536                .send(Message::Extended { ext_id: 0, payload })
5537                .await
5538                .unwrap();
5539
5540            let mut bf = Bitfield::new(1);
5541            bf.set(0);
5542            framed_write
5543                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
5544                .await
5545                .unwrap();
5546
5547            framed_write.send(Message::Unchoke).await.unwrap();
5548
5549            while let Some(Ok(msg)) = framed_read.next().await {
5550                match msg {
5551                    Message::Request {
5552                        index: _,
5553                        begin,
5554                        length,
5555                    } => {
5556                        let start = begin as usize;
5557                        let end = start + length as usize;
5558                        framed_write
5559                            .send(Message::Piece {
5560                                index: 0,
5561                                begin,
5562                                data_0: Bytes::copy_from_slice(&mock_data[start..end]),
5563                                data_1: Bytes::new(),
5564                            })
5565                            .await
5566                            .unwrap();
5567                    }
5568                    Message::Interested => {}
5569                    _ => {}
5570                }
5571            }
5572        });
5573
5574        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
5575        loop {
5576            tokio::time::sleep(Duration::from_millis(100)).await;
5577            let stats = handle.stats().await.unwrap();
5578            if stats.state == TorrentState::Seeding {
5579                assert_eq!(stats.pieces_have, 1);
5580                break;
5581            }
5582            if tokio::time::Instant::now() > deadline {
5583                panic!("multi-chunk download did not complete within 5s");
5584            }
5585        }
5586
5587        handle.shutdown().await.unwrap();
5588        mock_task.abort();
5589    }
5590
5591    // ---- Test 14: Seeder/Leecher integration with two actors ----
5592
5593    #[tokio::test]
5594    async fn seeder_leecher_integration() {
5595        // Seeder has all data, leecher has none. Connect them via TCP.
5596        let data = vec![0xDDu8; 32768]; // 32 KiB, 2 pieces of 16384
5597        let piece_length = 16384u64;
5598        let meta = make_test_torrent(&data, piece_length);
5599        let info_hash = meta.info_hash;
5600
5601        // Seeder: storage pre-filled
5602        let seeder_storage = make_seeded_storage(&data, piece_length);
5603
5604        // For the seeder, we need a from_torrent variant that starts in Complete state
5605        // but still serves pieces. Since our actor starts in Downloading, the seeder
5606        // will just be a mock that accepts and serves.
5607
5608        // Use a mock seeder approach instead (manual protocol handling):
5609        let seeder_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5610        let seeder_addr = seeder_listener.local_addr().unwrap();
5611
5612        let seeder_task = tokio::spawn(async move {
5613            let (stream, _addr) = seeder_listener.accept().await.unwrap();
5614            let (reader, writer) = tokio::io::split(stream);
5615            let mut writer = writer;
5616            let mut reader = reader;
5617
5618            // Handshake
5619            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
5620            reader.read_exact(&mut hs_buf).await.unwrap();
5621            let their_hs = Handshake::from_bytes(&hs_buf).unwrap();
5622            assert_eq!(their_hs.info_hash, info_hash);
5623
5624            let hs = Handshake::new(info_hash, PeerId::generate().0);
5625            writer.write_all(&hs.to_bytes()).await.unwrap();
5626            writer.flush().await.unwrap();
5627
5628            let mut framed_read = FramedRead::new(reader, MessageCodec::new());
5629            let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
5630
5631            // Read ext handshake
5632            let _msg = framed_read.next().await;
5633
5634            // Send ext handshake
5635            let ext_hs = ExtHandshake::new();
5636            let payload = ext_hs.to_bytes().unwrap();
5637            framed_write
5638                .send(Message::Extended { ext_id: 0, payload })
5639                .await
5640                .unwrap();
5641
5642            // Send bitfield (all pieces)
5643            let mut bf = Bitfield::new(2);
5644            bf.set(0);
5645            bf.set(1);
5646            framed_write
5647                .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
5648                .await
5649                .unwrap();
5650
5651            // Unchoke
5652            framed_write.send(Message::Unchoke).await.unwrap();
5653
5654            // Serve requests
5655            while let Some(Ok(msg)) = framed_read.next().await {
5656                match msg {
5657                    Message::Request {
5658                        index,
5659                        begin,
5660                        length,
5661                    } => {
5662                        let piece_data = seeder_storage.read_chunk(index, begin, length).unwrap();
5663                        framed_write
5664                            .send(Message::Piece {
5665                                index,
5666                                begin,
5667                                data_0: Bytes::from(piece_data),
5668                                data_1: Bytes::new(),
5669                            })
5670                            .await
5671                            .unwrap();
5672                    }
5673                    Message::Interested => {}
5674                    _ => {}
5675                }
5676            }
5677        });
5678
5679        // Leecher: empty storage
5680        let leecher_storage = make_storage(&data, piece_length);
5681        let leecher_meta = make_test_torrent(&data, piece_length);
5682
5683        let leecher_config = test_config();
5684        let (latx, lamask) = test_alert_channel();
5685        let (ldh, ldm, _ldj) = test_register_disk(leecher_meta.info_hash, leecher_storage).await;
5686        let leecher = TorrentHandle::from_torrent(
5687            leecher_meta,
5688            irontide_core::TorrentVersion::V1Only,
5689            None,
5690            ldh,
5691            ldm,
5692            leecher_config,
5693            None,
5694            None,
5695            None,
5696            None,
5697            crate::slot_tuner::SlotTuner::disabled(4),
5698            latx,
5699            lamask,
5700            None,
5701            None,
5702            test_ban_manager(),
5703            test_ip_filter(),
5704            Arc::new(Vec::new()),
5705            None,
5706            None,
5707            Arc::new(crate::transport::NetworkFactory::tokio()),
5708            None, // M96: hash_pool
5709        )
5710        .await
5711        .unwrap();
5712
5713        // Add seeder as a peer
5714        leecher
5715            .add_peers(vec![seeder_addr], PeerSource::Tracker)
5716            .await
5717            .unwrap();
5718
5719        // Give the connect interval time to fire (it ticks every 5s).
5720        // The actor's try_connect_peers runs on the timer, and also immediately
5721        // when peers are added via AddPeers command. Wait up to 10 seconds.
5722        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
5723        loop {
5724            tokio::time::sleep(Duration::from_millis(200)).await;
5725            let stats = leecher.stats().await.unwrap();
5726            if stats.state == TorrentState::Seeding {
5727                assert_eq!(stats.pieces_have, 2);
5728                assert_eq!(stats.pieces_total, 2);
5729                break;
5730            }
5731            if tokio::time::Instant::now() > deadline {
5732                let stats = leecher.stats().await.unwrap();
5733                panic!(
5734                    "seeder/leecher: leecher did not complete, state={:?}, have={}/{}, connected={}, available={}",
5735                    stats.state,
5736                    stats.pieces_have,
5737                    stats.pieces_total,
5738                    stats.peers_connected,
5739                    stats.peers_available,
5740                );
5741            }
5742        }
5743
5744        leecher.shutdown().await.unwrap();
5745        seeder_task.abort();
5746    }
5747
5748    // ---- Test 15: Magnet stats ----
5749
5750    #[tokio::test]
5751    async fn magnet_initial_stats() {
5752        let magnet = Magnet {
5753            info_hashes: irontide_core::InfoHashes::v1_only(
5754                Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
5755            ),
5756            display_name: Some("magnet test".into()),
5757            trackers: vec![],
5758            peers: vec![],
5759            selected_files: None,
5760        };
5761
5762        let (atx, amask) = test_alert_channel();
5763        let (dm, _dj) = test_disk_manager();
5764        let handle = TorrentHandle::from_magnet(
5765            magnet,
5766            dm,
5767            test_config(),
5768            None,
5769            None,
5770            None,
5771            None,
5772            crate::slot_tuner::SlotTuner::disabled(4),
5773            atx,
5774            amask,
5775            None,
5776            None,
5777            test_ban_manager(),
5778            test_ip_filter(),
5779            Arc::new(Vec::new()),
5780            None,
5781            None,
5782            Arc::new(crate::transport::NetworkFactory::tokio()),
5783            None, // M96: hash_pool
5784        )
5785        .await
5786        .unwrap();
5787
5788        let stats = handle.stats().await.unwrap();
5789        assert_eq!(stats.state, TorrentState::FetchingMetadata);
5790        assert_eq!(stats.pieces_total, 0);
5791        assert_eq!(stats.pieces_have, 0);
5792        assert_eq!(stats.downloaded, 0);
5793        assert_eq!(stats.uploaded, 0);
5794        assert_eq!(stats.peers_connected, 0);
5795        assert_eq!(stats.peers_available, 0);
5796
5797        handle.shutdown().await.unwrap();
5798    }
5799
5800    // ---- Test 16: Tracker manager is populated from torrent metadata ----
5801
5802    #[tokio::test]
5803    async fn tracker_populated_from_metadata() {
5804        use serde::Serialize;
5805
5806        let data = vec![0xAB; 16384];
5807        let hash = irontide_core::sha1(&data);
5808        let mut pieces = Vec::new();
5809        pieces.extend_from_slice(hash.as_bytes());
5810
5811        #[derive(Serialize)]
5812        struct Info<'a> {
5813            length: u64,
5814            name: &'a str,
5815            #[serde(rename = "piece length")]
5816            piece_length: u64,
5817            #[serde(with = "serde_bytes")]
5818            pieces: &'a [u8],
5819        }
5820
5821        #[derive(Serialize)]
5822        struct Torrent<'a> {
5823            announce: &'a str,
5824            info: Info<'a>,
5825        }
5826
5827        let t = Torrent {
5828            announce: "http://tracker.example.com:8080/announce",
5829            info: Info {
5830                length: data.len() as u64,
5831                name: "test",
5832                piece_length: 16384,
5833                pieces: &pieces,
5834            },
5835        };
5836
5837        let bytes = irontide_bencode::to_bytes(&t).unwrap();
5838        let meta = torrent_from_bytes(&bytes).unwrap();
5839        assert!(meta.announce.is_some());
5840
5841        let storage = make_storage(&data, 16384);
5842        let config = test_config();
5843
5844        // The torrent should start and announce to tracker (which will fail since
5845        // the tracker doesn't exist, but that's fine — failures are non-fatal).
5846        let (atx, amask) = test_alert_channel();
5847        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5848        let handle = TorrentHandle::from_torrent(
5849            meta,
5850            irontide_core::TorrentVersion::V1Only,
5851            None,
5852            dh,
5853            dm,
5854            config,
5855            None,
5856            None,
5857            None,
5858            None,
5859            crate::slot_tuner::SlotTuner::disabled(4),
5860            atx,
5861            amask,
5862            None,
5863            None,
5864            test_ban_manager(),
5865            test_ip_filter(),
5866            Arc::new(Vec::new()),
5867            None,
5868            None,
5869            Arc::new(crate::transport::NetworkFactory::tokio()),
5870            None, // M96: hash_pool
5871        )
5872        .await
5873        .unwrap();
5874
5875        let stats = handle.stats().await.unwrap();
5876        assert_eq!(stats.state, TorrentState::Downloading);
5877
5878        handle.shutdown().await.unwrap();
5879    }
5880
5881    // ---- Test 17: Private torrent with DHT=None works ----
5882
5883    #[tokio::test]
5884    async fn private_torrent_no_dht_field() {
5885        let data = vec![0xAB; 16384];
5886        let hash = irontide_core::sha1(&data);
5887        let mut pieces = Vec::new();
5888        pieces.extend_from_slice(hash.as_bytes());
5889
5890        use serde::Serialize;
5891
5892        #[derive(Serialize)]
5893        struct Info<'a> {
5894            length: u64,
5895            name: &'a str,
5896            #[serde(rename = "piece length")]
5897            piece_length: u64,
5898            #[serde(with = "serde_bytes")]
5899            pieces: &'a [u8],
5900            private: i64,
5901        }
5902
5903        #[derive(Serialize)]
5904        struct Torrent<'a> {
5905            announce: &'a str,
5906            info: Info<'a>,
5907        }
5908
5909        let t = Torrent {
5910            announce: "http://private-tracker.example.com/announce",
5911            info: Info {
5912                length: data.len() as u64,
5913                name: "private_test",
5914                piece_length: 16384,
5915                pieces: &pieces,
5916                private: 1,
5917            },
5918        };
5919
5920        let bytes = irontide_bencode::to_bytes(&t).unwrap();
5921        let meta = torrent_from_bytes(&bytes).unwrap();
5922        assert_eq!(meta.info.private, Some(1));
5923
5924        let storage = make_storage(&data, 16384);
5925        let config = test_config();
5926
5927        let (atx, amask) = test_alert_channel();
5928        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
5929        let handle = TorrentHandle::from_torrent(
5930            meta,
5931            irontide_core::TorrentVersion::V1Only,
5932            None,
5933            dh,
5934            dm,
5935            config,
5936            None,
5937            None,
5938            None,
5939            None,
5940            crate::slot_tuner::SlotTuner::disabled(4),
5941            atx,
5942            amask,
5943            None,
5944            None,
5945            test_ban_manager(),
5946            test_ip_filter(),
5947            Arc::new(Vec::new()),
5948            None,
5949            None,
5950            Arc::new(crate::transport::NetworkFactory::tokio()),
5951            None, // M96: hash_pool
5952        )
5953        .await
5954        .unwrap();
5955
5956        let stats = handle.stats().await.unwrap();
5957        assert_eq!(stats.state, TorrentState::Downloading);
5958
5959        handle.shutdown().await.unwrap();
5960    }
5961
5962    // ---- Test 18: Magnet defers tracker announce ----
5963
5964    #[tokio::test]
5965    async fn magnet_no_tracker_before_metadata() {
5966        let magnet = Magnet {
5967            info_hashes: irontide_core::InfoHashes::v1_only(
5968                Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
5969            ),
5970            display_name: Some("magnet test".into()),
5971            trackers: vec![],
5972            peers: vec![],
5973            selected_files: None,
5974        };
5975
5976        let (atx, amask) = test_alert_channel();
5977        let (dm, _dj) = test_disk_manager();
5978        let handle = TorrentHandle::from_magnet(
5979            magnet,
5980            dm,
5981            test_config(),
5982            None,
5983            None,
5984            None,
5985            None,
5986            crate::slot_tuner::SlotTuner::disabled(4),
5987            atx,
5988            amask,
5989            None,
5990            None,
5991            test_ban_manager(),
5992            test_ip_filter(),
5993            Arc::new(Vec::new()),
5994            None,
5995            None,
5996            Arc::new(crate::transport::NetworkFactory::tokio()),
5997            None, // M96: hash_pool
5998        )
5999        .await
6000        .unwrap();
6001
6002        let stats = handle.stats().await.unwrap();
6003        assert_eq!(stats.state, TorrentState::FetchingMetadata);
6004
6005        // With no trackers configured, no announces happen regardless of state.
6006        // Note: tracker announces ARE now allowed during FetchingMetadata for
6007        // magnets with &tr= URLs (needed to discover peers before metadata).
6008        tokio::time::sleep(Duration::from_millis(50)).await;
6009
6010        handle.shutdown().await.unwrap();
6011    }
6012
6013    // ---- Test 19: Pause and resume ----
6014
6015    #[tokio::test]
6016    async fn pause_and_resume() {
6017        let data = vec![0xEEu8; 32768];
6018        let meta = make_test_torrent(&data, 16384);
6019        let storage = make_storage(&data, 16384);
6020        let config = test_config();
6021        let (atx, amask) = test_alert_channel();
6022        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6023        let handle = TorrentHandle::from_torrent(
6024            meta,
6025            irontide_core::TorrentVersion::V1Only,
6026            None,
6027            dh,
6028            dm,
6029            config,
6030            None,
6031            None,
6032            None,
6033            None,
6034            crate::slot_tuner::SlotTuner::disabled(4),
6035            atx,
6036            amask,
6037            None,
6038            None,
6039            test_ban_manager(),
6040            test_ip_filter(),
6041            Arc::new(Vec::new()),
6042            None,
6043            None,
6044            Arc::new(crate::transport::NetworkFactory::tokio()),
6045            None, // M96: hash_pool
6046        )
6047        .await
6048        .unwrap();
6049
6050        let stats = handle.stats().await.unwrap();
6051        assert_eq!(stats.state, TorrentState::Downloading);
6052
6053        handle.pause().await.unwrap();
6054        tokio::time::sleep(Duration::from_millis(50)).await;
6055        let stats = handle.stats().await.unwrap();
6056        assert_eq!(stats.state, TorrentState::Paused);
6057
6058        handle.resume().await.unwrap();
6059        tokio::time::sleep(Duration::from_millis(50)).await;
6060        let stats = handle.stats().await.unwrap();
6061        assert_eq!(stats.state, TorrentState::Downloading);
6062
6063        handle.shutdown().await.unwrap();
6064    }
6065
6066    // ---- Test 20: Pause already paused is noop ----
6067
6068    #[tokio::test]
6069    async fn pause_already_paused_is_noop() {
6070        let data = vec![0xEEu8; 32768];
6071        let meta = make_test_torrent(&data, 16384);
6072        let storage = make_storage(&data, 16384);
6073        let config = test_config();
6074        let (atx, amask) = test_alert_channel();
6075        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6076        let handle = TorrentHandle::from_torrent(
6077            meta,
6078            irontide_core::TorrentVersion::V1Only,
6079            None,
6080            dh,
6081            dm,
6082            config,
6083            None,
6084            None,
6085            None,
6086            None,
6087            crate::slot_tuner::SlotTuner::disabled(4),
6088            atx,
6089            amask,
6090            None,
6091            None,
6092            test_ban_manager(),
6093            test_ip_filter(),
6094            Arc::new(Vec::new()),
6095            None,
6096            None,
6097            Arc::new(crate::transport::NetworkFactory::tokio()),
6098            None, // M96: hash_pool
6099        )
6100        .await
6101        .unwrap();
6102
6103        handle.pause().await.unwrap();
6104        tokio::time::sleep(Duration::from_millis(50)).await;
6105        handle.pause().await.unwrap(); // double pause is fine
6106        tokio::time::sleep(Duration::from_millis(50)).await;
6107        let stats = handle.stats().await.unwrap();
6108        assert_eq!(stats.state, TorrentState::Paused);
6109
6110        handle.shutdown().await.unwrap();
6111    }
6112
6113    // ---- Test 21: Incoming request served from storage ----
6114    //
6115    // Phase 1: Mock seeder feeds piece 0 to the torrent so it becomes verified.
6116    // Phase 2: Mock leecher connects and requests piece 0, verifying upload pipeline.
6117
6118    #[tokio::test]
6119    async fn incoming_request_served_from_storage() {
6120        let data = vec![0xABu8; 16384];
6121        let meta = make_test_torrent(&data, 16384);
6122        let info_hash = meta.info_hash;
6123        let storage = make_storage(&data, 16384);
6124
6125        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6126        let listen_addr = listener.local_addr().unwrap();
6127        drop(listener);
6128
6129        let config = TorrentConfig {
6130            listen_port: listen_addr.port(),
6131            ..test_config()
6132        };
6133
6134        let (atx, amask) = test_alert_channel();
6135        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6136        let handle = TorrentHandle::from_torrent(
6137            meta,
6138            irontide_core::TorrentVersion::V1Only,
6139            None,
6140            dh,
6141            dm,
6142            config,
6143            None,
6144            None,
6145            None,
6146            None,
6147            crate::slot_tuner::SlotTuner::disabled(4),
6148            atx,
6149            amask,
6150            None,
6151            None,
6152            test_ban_manager(),
6153            test_ip_filter(),
6154            Arc::new(Vec::new()),
6155            None,
6156            None,
6157            Arc::new(crate::transport::NetworkFactory::tokio()),
6158            None, // M96: hash_pool
6159        )
6160        .await
6161        .unwrap();
6162
6163        tokio::time::sleep(Duration::from_millis(50)).await;
6164
6165        // Phase 1: Seed the torrent with piece 0
6166        let seed_data = data.clone();
6167        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6168        let seeder_task = tokio::spawn({
6169            let info_hash = info_hash;
6170            async move {
6171                let (reader, writer) = tokio::io::split(seed_stream);
6172                let mut writer = writer;
6173                let mut reader = reader;
6174
6175                let hs = Handshake::new(
6176                    info_hash,
6177                    Id20::from_hex("6666666666666666666666666666666666666666").unwrap(),
6178                );
6179                writer.write_all(&hs.to_bytes()).await.unwrap();
6180                writer.flush().await.unwrap();
6181                let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6182                reader.read_exact(&mut hs_buf).await.unwrap();
6183
6184                let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6185                let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6186
6187                let _msg = framed_read.next().await; // ext handshake
6188                let ext_hs = ExtHandshake::new();
6189                let payload = ext_hs.to_bytes().unwrap();
6190                framed_write
6191                    .send(Message::Extended { ext_id: 0, payload })
6192                    .await
6193                    .unwrap();
6194
6195                // Send bitfield + unchoke
6196                let mut bf = Bitfield::new(1);
6197                bf.set(0);
6198                framed_write
6199                    .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6200                    .await
6201                    .unwrap();
6202                framed_write.send(Message::Unchoke).await.unwrap();
6203
6204                // Respond to requests
6205                while let Some(Ok(msg)) = framed_read.next().await {
6206                    match msg {
6207                        Message::Request {
6208                            index,
6209                            begin,
6210                            length,
6211                        } => {
6212                            let start = begin as usize;
6213                            let end = start + length as usize;
6214                            framed_write
6215                                .send(Message::Piece {
6216                                    index,
6217                                    begin,
6218                                    data_0: Bytes::copy_from_slice(&seed_data[start..end]),
6219                                    data_1: Bytes::new(),
6220                                })
6221                                .await
6222                                .unwrap();
6223                        }
6224                        Message::Interested => {}
6225                        _ => {}
6226                    }
6227                }
6228            }
6229        });
6230
6231        // Wait for download to complete
6232        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6233        loop {
6234            tokio::time::sleep(Duration::from_millis(100)).await;
6235            let stats = handle.stats().await.unwrap();
6236            if stats.pieces_have == 1 {
6237                break;
6238            }
6239            if tokio::time::Instant::now() > deadline {
6240                panic!("piece download did not complete within 5s");
6241            }
6242        }
6243
6244        // Phase 2: Connect a mock leecher to request piece 0 back
6245        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6246        let expected_data = data.clone();
6247        let leecher_task = tokio::spawn({
6248            let info_hash = info_hash;
6249            async move {
6250                let (reader, writer) = tokio::io::split(leech_stream);
6251                let mut writer = writer;
6252                let mut reader = reader;
6253
6254                let hs = Handshake::new(
6255                    info_hash,
6256                    Id20::from_hex("7777777777777777777777777777777777777777").unwrap(),
6257                );
6258                writer.write_all(&hs.to_bytes()).await.unwrap();
6259                writer.flush().await.unwrap();
6260                let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6261                reader.read_exact(&mut hs_buf).await.unwrap();
6262
6263                let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6264                let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6265
6266                let _msg = framed_read.next().await; // ext handshake
6267                let ext_hs = ExtHandshake::new();
6268                let payload = ext_hs.to_bytes().unwrap();
6269                framed_write
6270                    .send(Message::Extended { ext_id: 0, payload })
6271                    .await
6272                    .unwrap();
6273
6274                // Send Interested and wait for Unchoke
6275                framed_write.send(Message::Interested).await.unwrap();
6276
6277                let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
6278                loop {
6279                    tokio::select! {
6280                        msg = framed_read.next() => {
6281                            match msg {
6282                                Some(Ok(Message::Unchoke)) => { break; }
6283                                Some(Ok(_)) => {}
6284                                _ => panic!("connection closed before unchoke"),
6285                            }
6286                        }
6287                        _ = tokio::time::sleep_until(deadline) => {
6288                            panic!("timed out waiting for unchoke");
6289                        }
6290                    }
6291                }
6292
6293                // Request piece 0
6294                framed_write
6295                    .send(Message::Request {
6296                        index: 0,
6297                        begin: 0,
6298                        length: 16384,
6299                    })
6300                    .await
6301                    .unwrap();
6302
6303                // Read Piece response
6304                let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6305                loop {
6306                    tokio::select! {
6307                        msg = framed_read.next() => {
6308                            match msg {
6309                                Some(Ok(Message::Piece { index, begin, data_0, data_1 })) => {
6310                                    assert_eq!(index, 0);
6311                                    assert_eq!(begin, 0);
6312                                    let _ = &data_1; // empty after wire round-trip
6313                                    assert_eq!(data_0.as_ref(), expected_data.as_slice());
6314                                    return; // success
6315                                }
6316                                Some(Ok(_)) => {}
6317                                Some(Err(e)) => panic!("error reading: {e}"),
6318                                None => panic!("connection closed before piece"),
6319                            }
6320                        }
6321                        _ = tokio::time::sleep_until(deadline) => {
6322                            panic!("timed out waiting for piece data");
6323                        }
6324                    }
6325                }
6326            }
6327        });
6328
6329        // Wait for leecher to complete
6330        let result = tokio::time::timeout(Duration::from_secs(20), leecher_task).await;
6331        match result {
6332            Ok(Ok(())) => {}
6333            Ok(Err(e)) => panic!("leecher task panicked: {e}"),
6334            Err(_) => panic!("test timed out"),
6335        }
6336
6337        // Verify uploaded bytes
6338        let stats = handle.stats().await.unwrap();
6339        assert!(
6340            stats.uploaded > 0,
6341            "expected uploaded > 0, got {}",
6342            stats.uploaded
6343        );
6344
6345        handle.shutdown().await.unwrap();
6346        seeder_task.abort();
6347    }
6348
6349    // ---- Test 22: Seed ratio limit stops torrent ----
6350
6351    #[tokio::test]
6352    async fn seed_ratio_limit_stops_torrent() {
6353        // 1-piece torrent, ratio limit = 1.0
6354        // After downloading 16384 bytes and uploading 16384 bytes, ratio = 1.0 → stop
6355        let data = vec![0xCCu8; 16384];
6356        let meta = make_test_torrent(&data, 16384);
6357        let info_hash = meta.info_hash;
6358        let storage = make_storage(&data, 16384);
6359
6360        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6361        let listen_addr = listener.local_addr().unwrap();
6362        drop(listener);
6363
6364        let config = TorrentConfig {
6365            listen_port: listen_addr.port(),
6366            seed_ratio_limit: Some(1.0),
6367            ..test_config()
6368        };
6369
6370        let (atx, amask) = test_alert_channel();
6371        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6372        let handle = TorrentHandle::from_torrent(
6373            meta,
6374            irontide_core::TorrentVersion::V1Only,
6375            None,
6376            dh,
6377            dm,
6378            config,
6379            None,
6380            None,
6381            None,
6382            None,
6383            crate::slot_tuner::SlotTuner::disabled(4),
6384            atx,
6385            amask,
6386            None,
6387            None,
6388            test_ban_manager(),
6389            test_ip_filter(),
6390            Arc::new(Vec::new()),
6391            None,
6392            None,
6393            Arc::new(crate::transport::NetworkFactory::tokio()),
6394            None, // M96: hash_pool
6395        )
6396        .await
6397        .unwrap();
6398
6399        tokio::time::sleep(Duration::from_millis(50)).await;
6400
6401        // Phase 1: Seed the torrent with piece 0
6402        let seed_data = data.clone();
6403        let seed_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6404        let seeder_task = tokio::spawn({
6405            let info_hash = info_hash;
6406            async move {
6407                let (reader, writer) = tokio::io::split(seed_stream);
6408                let mut writer = writer;
6409                let mut reader = reader;
6410
6411                let hs = Handshake::new(
6412                    info_hash,
6413                    Id20::from_hex("8888888888888888888888888888888888888888").unwrap(),
6414                );
6415                writer.write_all(&hs.to_bytes()).await.unwrap();
6416                writer.flush().await.unwrap();
6417                let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6418                reader.read_exact(&mut hs_buf).await.unwrap();
6419
6420                let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6421                let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6422
6423                let _msg = framed_read.next().await;
6424                let ext_hs = ExtHandshake::new();
6425                let payload = ext_hs.to_bytes().unwrap();
6426                framed_write
6427                    .send(Message::Extended { ext_id: 0, payload })
6428                    .await
6429                    .unwrap();
6430
6431                let mut bf = Bitfield::new(1);
6432                bf.set(0);
6433                framed_write
6434                    .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
6435                    .await
6436                    .unwrap();
6437                framed_write.send(Message::Unchoke).await.unwrap();
6438
6439                while let Some(Ok(msg)) = framed_read.next().await {
6440                    match msg {
6441                        Message::Request {
6442                            index,
6443                            begin,
6444                            length,
6445                        } => {
6446                            let start = begin as usize;
6447                            let end = start + length as usize;
6448                            framed_write
6449                                .send(Message::Piece {
6450                                    index,
6451                                    begin,
6452                                    data_0: Bytes::copy_from_slice(&seed_data[start..end]),
6453                                    data_1: Bytes::new(),
6454                                })
6455                                .await
6456                                .unwrap();
6457                        }
6458                        Message::Interested => {}
6459                        _ => {}
6460                    }
6461                }
6462            }
6463        });
6464
6465        // Wait for download to complete (transitions to Seeding)
6466        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
6467        loop {
6468            tokio::time::sleep(Duration::from_millis(100)).await;
6469            let stats = handle.stats().await.unwrap();
6470            if stats.state == TorrentState::Seeding {
6471                break;
6472            }
6473            if tokio::time::Instant::now() > deadline {
6474                panic!("download did not complete within 5s");
6475            }
6476        }
6477
6478        // Phase 2: Connect leecher to request piece 0 — this should trigger ratio limit
6479        let leech_stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6480        let leecher_task = tokio::spawn({
6481            let info_hash = info_hash;
6482            async move {
6483                let (reader, writer) = tokio::io::split(leech_stream);
6484                let mut writer = writer;
6485                let mut reader = reader;
6486
6487                let hs = Handshake::new(
6488                    info_hash,
6489                    Id20::from_hex("9999999999999999999999999999999999999999").unwrap(),
6490                );
6491                writer.write_all(&hs.to_bytes()).await.unwrap();
6492                writer.flush().await.unwrap();
6493                let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6494                reader.read_exact(&mut hs_buf).await.unwrap();
6495
6496                let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6497                let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6498
6499                let _msg = framed_read.next().await;
6500                let ext_hs = ExtHandshake::new();
6501                let payload = ext_hs.to_bytes().unwrap();
6502                framed_write
6503                    .send(Message::Extended { ext_id: 0, payload })
6504                    .await
6505                    .unwrap();
6506
6507                framed_write.send(Message::Interested).await.unwrap();
6508
6509                // Wait for unchoke
6510                let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
6511                loop {
6512                    tokio::select! {
6513                        msg = framed_read.next() => {
6514                            match msg {
6515                                Some(Ok(Message::Unchoke)) => break,
6516                                Some(Ok(_)) => {}
6517                                _ => return, // connection may close due to ratio shutdown
6518                            }
6519                        }
6520                        _ = tokio::time::sleep_until(deadline) => return,
6521                    }
6522                }
6523
6524                // Request piece 0
6525                framed_write
6526                    .send(Message::Request {
6527                        index: 0,
6528                        begin: 0,
6529                        length: 16384,
6530                    })
6531                    .await
6532                    .unwrap();
6533
6534                // Read until connection closes (the torrent may stop and disconnect us)
6535                while let Some(Ok(_msg)) = framed_read.next().await {}
6536            }
6537        });
6538
6539        // Wait for state to become Stopped
6540        let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
6541        loop {
6542            tokio::time::sleep(Duration::from_millis(100)).await;
6543            let stats = handle.stats().await.unwrap();
6544            if stats.state == TorrentState::Stopped {
6545                assert!(
6546                    stats.uploaded >= 16384,
6547                    "expected uploaded >= 16384, got {}",
6548                    stats.uploaded
6549                );
6550                break;
6551            }
6552            if tokio::time::Instant::now() > deadline {
6553                let stats = handle.stats().await.unwrap();
6554                panic!(
6555                    "expected Stopped, got {:?}, uploaded={}, downloaded={}",
6556                    stats.state, stats.uploaded, stats.downloaded
6557                );
6558            }
6559        }
6560
6561        handle.shutdown().await.unwrap();
6562        seeder_task.abort();
6563        leecher_task.abort();
6564    }
6565
6566    // ---- Test 23: Resume with seeded storage starts as seeder ----
6567
6568    #[tokio::test]
6569    async fn resume_with_seeded_storage() {
6570        let data = vec![0xDDu8; 32768]; // 2 pieces
6571        let meta = make_test_torrent(&data, 16384);
6572        let storage = make_seeded_storage(&data, 16384);
6573        let config = test_config();
6574
6575        let (atx, amask) = test_alert_channel();
6576        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6577        let handle = TorrentHandle::from_torrent(
6578            meta,
6579            irontide_core::TorrentVersion::V1Only,
6580            None,
6581            dh,
6582            dm,
6583            config,
6584            None,
6585            None,
6586            None,
6587            None,
6588            crate::slot_tuner::SlotTuner::disabled(4),
6589            atx,
6590            amask,
6591            None,
6592            None,
6593            test_ban_manager(),
6594            test_ip_filter(),
6595            Arc::new(Vec::new()),
6596            None,
6597            None,
6598            Arc::new(crate::transport::NetworkFactory::tokio()),
6599            None, // M96: hash_pool
6600        )
6601        .await
6602        .unwrap();
6603
6604        // Give the actor time to verify existing pieces
6605        tokio::time::sleep(Duration::from_millis(100)).await;
6606
6607        let stats = handle.stats().await.unwrap();
6608        assert_eq!(
6609            stats.state,
6610            TorrentState::Seeding,
6611            "should start as seeder with all pieces verified"
6612        );
6613        assert_eq!(stats.pieces_have, 2);
6614        assert_eq!(stats.pieces_total, 2);
6615
6616        handle.shutdown().await.unwrap();
6617    }
6618
6619    // ---- Test: save_resume_data captures state ----
6620
6621    #[tokio::test]
6622    async fn save_resume_data_captures_state() {
6623        let data = vec![0xAB; 32768];
6624        let meta = make_test_torrent(&data, 16384);
6625        let info_hash = meta.info_hash;
6626        let storage = make_storage(&data, 16384);
6627        let config = test_config();
6628
6629        let (atx, amask) = test_alert_channel();
6630        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6631        let handle = TorrentHandle::from_torrent(
6632            meta,
6633            irontide_core::TorrentVersion::V1Only,
6634            None,
6635            dh,
6636            dm,
6637            config,
6638            None,
6639            None,
6640            None,
6641            None,
6642            crate::slot_tuner::SlotTuner::disabled(4),
6643            atx,
6644            amask,
6645            None,
6646            None,
6647            test_ban_manager(),
6648            test_ip_filter(),
6649            Arc::new(Vec::new()),
6650            None,
6651            None,
6652            Arc::new(crate::transport::NetworkFactory::tokio()),
6653            None, // M96: hash_pool
6654        )
6655        .await
6656        .unwrap();
6657
6658        // Give actor time to start
6659        tokio::time::sleep(Duration::from_millis(50)).await;
6660
6661        let rd = handle.save_resume_data().await.unwrap();
6662
6663        assert_eq!(rd.file_format, "libtorrent resume file");
6664        assert_eq!(rd.file_version, 1);
6665        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
6666        assert_eq!(rd.name, "test");
6667        assert_eq!(rd.save_path, "/tmp");
6668        assert_eq!(rd.paused, 0);
6669        // No pieces downloaded yet — bitfield should be all zeros
6670        assert!(!rd.pieces.is_empty());
6671        // Stats should be zero for a freshly started torrent with no peers
6672        assert_eq!(rd.total_uploaded, 0);
6673        assert_eq!(rd.total_downloaded, 0);
6674
6675        handle.shutdown().await.unwrap();
6676    }
6677
6678    // ---- Test: save_resume_data for seeder ----
6679
6680    #[tokio::test]
6681    async fn save_resume_data_seeder() {
6682        let data = vec![0xCD; 32768];
6683        let meta = make_test_torrent(&data, 16384);
6684        let info_hash = meta.info_hash;
6685        let storage = make_seeded_storage(&data, 16384);
6686        let config = test_config();
6687
6688        let (atx, amask) = test_alert_channel();
6689        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6690        let handle = TorrentHandle::from_torrent(
6691            meta,
6692            irontide_core::TorrentVersion::V1Only,
6693            None,
6694            dh,
6695            dm,
6696            config,
6697            None,
6698            None,
6699            None,
6700            None,
6701            crate::slot_tuner::SlotTuner::disabled(4),
6702            atx,
6703            amask,
6704            None,
6705            None,
6706            test_ban_manager(),
6707            test_ip_filter(),
6708            Arc::new(Vec::new()),
6709            None,
6710            None,
6711            Arc::new(crate::transport::NetworkFactory::tokio()),
6712            None, // M96: hash_pool
6713        )
6714        .await
6715        .unwrap();
6716
6717        // Give actor time to verify pieces and switch to seeding
6718        tokio::time::sleep(Duration::from_millis(100)).await;
6719
6720        let rd = handle.save_resume_data().await.unwrap();
6721
6722        assert_eq!(rd.info_hash, info_hash.as_bytes().to_vec());
6723        assert_eq!(rd.name, "test");
6724        assert_eq!(rd.seed_mode, 1, "seeder should have seed_mode=1");
6725        assert_eq!(rd.paused, 0);
6726        // All pieces should be marked in the bitfield
6727        // 2 pieces -> 1 byte, top 2 bits set = 0b1100_0000 = 0xC0
6728        assert_eq!(rd.pieces.len(), 1);
6729        assert_eq!(
6730            rd.pieces[0] & 0xC0,
6731            0xC0,
6732            "both pieces should be marked complete"
6733        );
6734
6735        handle.shutdown().await.unwrap();
6736    }
6737
6738    // ---- Test: save_resume_data for paused torrent ----
6739
6740    #[tokio::test]
6741    async fn save_resume_data_paused() {
6742        let data = vec![0xEF; 16384];
6743        let meta = make_test_torrent(&data, 16384);
6744        let storage = make_storage(&data, 16384);
6745        let config = test_config();
6746
6747        let (atx, amask) = test_alert_channel();
6748        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6749        let handle = TorrentHandle::from_torrent(
6750            meta,
6751            irontide_core::TorrentVersion::V1Only,
6752            None,
6753            dh,
6754            dm,
6755            config,
6756            None,
6757            None,
6758            None,
6759            None,
6760            crate::slot_tuner::SlotTuner::disabled(4),
6761            atx,
6762            amask,
6763            None,
6764            None,
6765            test_ban_manager(),
6766            test_ip_filter(),
6767            Arc::new(Vec::new()),
6768            None,
6769            None,
6770            Arc::new(crate::transport::NetworkFactory::tokio()),
6771            None, // M96: hash_pool
6772        )
6773        .await
6774        .unwrap();
6775
6776        tokio::time::sleep(Duration::from_millis(50)).await;
6777        handle.pause().await.unwrap();
6778        tokio::time::sleep(Duration::from_millis(50)).await;
6779
6780        let rd = handle.save_resume_data().await.unwrap();
6781        assert_eq!(rd.paused, 1, "paused torrent should have paused=1");
6782        assert_eq!(rd.seed_mode, 0);
6783
6784        handle.shutdown().await.unwrap();
6785    }
6786
6787    // ---- Test: set_file_priority and read back ----
6788
6789    #[tokio::test]
6790    async fn set_file_priority_and_read_back() {
6791        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
6792        let mut torrent_bytes = b"d4:info".to_vec();
6793        torrent_bytes.extend_from_slice(info_bytes);
6794        torrent_bytes.push(b'e');
6795
6796        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
6797        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
6798        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
6799        let config = TorrentConfig {
6800            listen_port: 0,
6801            ..Default::default()
6802        };
6803
6804        let (atx, amask) = test_alert_channel();
6805        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6806        let handle = TorrentHandle::from_torrent(
6807            meta,
6808            irontide_core::TorrentVersion::V1Only,
6809            None,
6810            dh,
6811            dm,
6812            config,
6813            None,
6814            None,
6815            None,
6816            None,
6817            crate::slot_tuner::SlotTuner::disabled(4),
6818            atx,
6819            amask,
6820            None,
6821            None,
6822            test_ban_manager(),
6823            test_ip_filter(),
6824            Arc::new(Vec::new()),
6825            None,
6826            None,
6827            Arc::new(crate::transport::NetworkFactory::tokio()),
6828            None, // M96: hash_pool
6829        )
6830        .await
6831        .unwrap();
6832
6833        // Default priorities should all be Normal
6834        let prios = handle.file_priorities().await.unwrap();
6835        assert_eq!(prios.len(), 2);
6836        assert!(prios.iter().all(|p| *p == FilePriority::Normal));
6837
6838        // Set file 0 to Skip
6839        handle
6840            .set_file_priority(0, FilePriority::Skip)
6841            .await
6842            .unwrap();
6843
6844        let prios = handle.file_priorities().await.unwrap();
6845        assert_eq!(prios[0], FilePriority::Skip);
6846        assert_eq!(prios[1], FilePriority::Normal);
6847
6848        // Invalid index should error
6849        let result = handle.set_file_priority(99, FilePriority::High).await;
6850        assert!(result.is_err());
6851
6852        handle.shutdown().await.unwrap();
6853        tokio::time::sleep(Duration::from_millis(50)).await;
6854    }
6855
6856    #[tokio::test]
6857    async fn resume_data_preserves_file_priorities() {
6858        let info_bytes = b"d5:filesld6:lengthi100e4:pathl5:a.bineed6:lengthi100e4:pathl5:b.bineee4:name4:test12:piece lengthi100e6:pieces40:AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBe";
6859        let mut torrent_bytes = b"d4:info".to_vec();
6860        torrent_bytes.extend_from_slice(info_bytes);
6861        torrent_bytes.push(b'e');
6862
6863        let meta = irontide_core::torrent_from_bytes(&torrent_bytes).unwrap();
6864        let lengths = Lengths::new(200, 100, DEFAULT_CHUNK_SIZE);
6865        let storage: Arc<dyn TorrentStorage> = Arc::new(MemoryStorage::new(lengths));
6866        let config = TorrentConfig {
6867            listen_port: 0,
6868            ..Default::default()
6869        };
6870
6871        let (atx, amask) = test_alert_channel();
6872        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6873        let handle = TorrentHandle::from_torrent(
6874            meta,
6875            irontide_core::TorrentVersion::V1Only,
6876            None,
6877            dh,
6878            dm,
6879            config,
6880            None,
6881            None,
6882            None,
6883            None,
6884            crate::slot_tuner::SlotTuner::disabled(4),
6885            atx,
6886            amask,
6887            None,
6888            None,
6889            test_ban_manager(),
6890            test_ip_filter(),
6891            Arc::new(Vec::new()),
6892            None,
6893            None,
6894            Arc::new(crate::transport::NetworkFactory::tokio()),
6895            None, // M96: hash_pool
6896        )
6897        .await
6898        .unwrap();
6899
6900        // Set file priorities
6901        handle
6902            .set_file_priority(0, FilePriority::High)
6903            .await
6904            .unwrap();
6905        handle
6906            .set_file_priority(1, FilePriority::Skip)
6907            .await
6908            .unwrap();
6909
6910        // Save resume data
6911        let rd = handle.save_resume_data().await.unwrap();
6912        assert_eq!(rd.file_priority, vec![7, 0]); // High=7, Skip=0
6913
6914        // Verify bencode round-trip
6915        let encoded = irontide_bencode::to_bytes(&rd).unwrap();
6916        let decoded: irontide_core::FastResumeData =
6917            irontide_bencode::from_bytes(&encoded).unwrap();
6918        assert_eq!(decoded.file_priority, vec![7, 0]);
6919
6920        handle.shutdown().await.unwrap();
6921        tokio::time::sleep(Duration::from_millis(50)).await;
6922    }
6923
6924    // ---- Rate limiting integration tests (M14) ----
6925
6926    #[tokio::test]
6927    async fn upload_rate_limiting_caps_throughput() {
6928        // Test that per-torrent upload rate limiting gates serve_incoming_requests.
6929        // We use a very low rate (1 KB/s) so the 16 KB piece requires ~16 seconds.
6930        // We verify: 1) piece does NOT arrive within 200ms (bucket too small),
6931        //            2) the torrent actor is alive and functional.
6932        let data = vec![0xAB; 16384]; // 1 piece
6933        let meta = make_test_torrent(&data, 16384);
6934        let info_hash = meta.info_hash;
6935        let storage = make_seeded_storage(&data, 16384);
6936
6937        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6938        let listen_addr = listener.local_addr().unwrap();
6939
6940        let config = TorrentConfig {
6941            listen_port: listen_addr.port(),
6942            upload_rate_limit: 1024, // 1 KB/s — way too slow for 16 KB chunk
6943            ..test_config()
6944        };
6945
6946        drop(listener);
6947        let (atx, amask) = test_alert_channel();
6948        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
6949        let handle = TorrentHandle::from_torrent(
6950            meta,
6951            irontide_core::TorrentVersion::V1Only,
6952            None,
6953            dh,
6954            dm,
6955            config,
6956            None,
6957            None,
6958            None,
6959            None,
6960            crate::slot_tuner::SlotTuner::disabled(4),
6961            atx,
6962            amask,
6963            None,
6964            None,
6965            test_ban_manager(),
6966            test_ip_filter(),
6967            Arc::new(Vec::new()),
6968            None,
6969            None,
6970            Arc::new(crate::transport::NetworkFactory::tokio()),
6971            None, // M96: hash_pool
6972        )
6973        .await
6974        .unwrap();
6975
6976        tokio::time::sleep(Duration::from_millis(50)).await;
6977
6978        // Connect mock leecher (raw handshake + framed messages)
6979        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
6980        let (reader, writer) = tokio::io::split(stream);
6981        let mut writer = writer;
6982        let mut reader = reader;
6983
6984        let hs = Handshake::new(
6985            info_hash,
6986            Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
6987        );
6988        writer.write_all(&hs.to_bytes()).await.unwrap();
6989        writer.flush().await.unwrap();
6990        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
6991        reader.read_exact(&mut hs_buf).await.unwrap();
6992
6993        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
6994        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
6995
6996        // Read ext handshake + bitfield
6997        let _msg = framed_read.next().await;
6998        let ext_hs = ExtHandshake::new();
6999        let payload = ext_hs.to_bytes().unwrap();
7000        framed_write
7001            .send(Message::Extended { ext_id: 0, payload })
7002            .await
7003            .unwrap();
7004
7005        // Read the bitfield
7006        let _bf_msg = framed_read.next().await;
7007
7008        // Express interest
7009        framed_write.send(Message::Interested).await.unwrap();
7010
7011        // Wait for unchoke
7012        let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
7013        loop {
7014            tokio::select! {
7015                msg = framed_read.next() => {
7016                    match msg {
7017                        Some(Ok(Message::Unchoke)) => break,
7018                        Some(Ok(_)) => {}
7019                        _ => panic!("connection closed before unchoke"),
7020                    }
7021                }
7022                _ = tokio::time::sleep_until(deadline) => {
7023                    panic!("timed out waiting for unchoke");
7024                }
7025            }
7026        }
7027
7028        // Request piece 0
7029        framed_write
7030            .send(Message::Request {
7031                index: 0,
7032                begin: 0,
7033                length: 16384,
7034            })
7035            .await
7036            .unwrap();
7037
7038        // At 1 KB/s, the bucket accumulates ~100 bytes per 100ms tick (max burst = 1024).
7039        // A 16 KB chunk needs 16384 tokens, so it should NOT be served quickly.
7040        // We wait 2 seconds — at 1 KB/s we'd have at most 2 KB, still < 16 KB.
7041        let mut got_piece = false;
7042        match tokio::time::timeout(Duration::from_secs(2), async {
7043            loop {
7044                match framed_read.next().await {
7045                    Some(Ok(Message::Piece { .. })) => return true,
7046                    Some(Ok(_)) => continue,
7047                    _ => return false,
7048                }
7049            }
7050        })
7051        .await
7052        {
7053            Ok(true) => got_piece = true,
7054            _ => {}
7055        }
7056
7057        // Piece should NOT have arrived in 2 seconds (would need 16s at 1 KB/s)
7058        assert!(
7059            !got_piece,
7060            "piece should be delayed by rate limiter (1 KB/s for 16 KB chunk)"
7061        );
7062
7063        // Verify actor is still alive
7064        let stats = handle.stats().await.unwrap();
7065        assert_eq!(stats.uploaded, 0); // nothing served yet
7066
7067        handle.shutdown().await.unwrap();
7068    }
7069
7070    #[tokio::test]
7071    async fn unlimited_rate_has_no_effect() {
7072        // Default config (rate = 0) should behave identically to pre-M14
7073        let data = vec![0xAB; 32768];
7074        let meta = make_test_torrent(&data, 16384);
7075        let storage = make_storage(&data, 16384);
7076        let config = test_config();
7077
7078        // Rate limits are 0 (unlimited) by default
7079        assert_eq!(config.upload_rate_limit, 0);
7080        assert_eq!(config.download_rate_limit, 0);
7081
7082        let (atx, amask) = test_alert_channel();
7083        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7084        let handle = TorrentHandle::from_torrent(
7085            meta,
7086            irontide_core::TorrentVersion::V1Only,
7087            None,
7088            dh,
7089            dm,
7090            config,
7091            None,
7092            None,
7093            None,
7094            None,
7095            crate::slot_tuner::SlotTuner::disabled(4),
7096            atx,
7097            amask,
7098            None,
7099            None,
7100            test_ban_manager(),
7101            test_ip_filter(),
7102            Arc::new(Vec::new()),
7103            None,
7104            None,
7105            Arc::new(crate::transport::NetworkFactory::tokio()),
7106            None, // M96: hash_pool
7107        )
7108        .await
7109        .unwrap();
7110
7111        let stats = handle.stats().await.unwrap();
7112        assert_eq!(stats.state, TorrentState::Downloading);
7113        assert_eq!(stats.pieces_total, 2);
7114
7115        handle.shutdown().await.unwrap();
7116    }
7117
7118    #[tokio::test]
7119    async fn download_rate_limiting_throttles_requests() {
7120        // Test that download_rate_limit prevents sending requests when budget exhausted.
7121        // With 1 KB/s limit and 16 KB chunks, budget is exhausted almost immediately.
7122        let data = vec![0xAB; 32768];
7123        let meta = make_test_torrent(&data, 16384);
7124        let info_hash = meta.info_hash;
7125        let storage = make_storage(&data, 16384);
7126
7127        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7128        let listen_addr = listener.local_addr().unwrap();
7129
7130        let config = TorrentConfig {
7131            listen_port: listen_addr.port(),
7132            download_rate_limit: 1024, // Very low: 1 KB/s
7133            ..test_config()
7134        };
7135
7136        drop(listener);
7137        let (atx, amask) = test_alert_channel();
7138        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7139        let handle = TorrentHandle::from_torrent(
7140            meta,
7141            irontide_core::TorrentVersion::V1Only,
7142            None,
7143            dh,
7144            dm,
7145            config,
7146            None,
7147            None,
7148            None,
7149            None,
7150            crate::slot_tuner::SlotTuner::disabled(4),
7151            atx,
7152            amask,
7153            None,
7154            None,
7155            test_ban_manager(),
7156            test_ip_filter(),
7157            Arc::new(Vec::new()),
7158            None,
7159            None,
7160            Arc::new(crate::transport::NetworkFactory::tokio()),
7161            None, // M96: hash_pool
7162        )
7163        .await
7164        .unwrap();
7165
7166        tokio::time::sleep(Duration::from_millis(50)).await;
7167
7168        // Connect mock seeder
7169        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
7170        let (reader, writer) = tokio::io::split(stream);
7171        let mut writer = writer;
7172        let mut reader = reader;
7173
7174        let hs = Handshake::new(
7175            info_hash,
7176            Id20::from_hex("cccccccccccccccccccccccccccccccccccccccc").unwrap(),
7177        );
7178        writer.write_all(&hs.to_bytes()).await.unwrap();
7179        writer.flush().await.unwrap();
7180        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
7181        reader.read_exact(&mut hs_buf).await.unwrap();
7182
7183        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
7184        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
7185
7186        // Read ext handshake
7187        let _msg = framed_read.next().await;
7188        let ext_hs = ExtHandshake::new();
7189        let payload = ext_hs.to_bytes().unwrap();
7190        framed_write
7191            .send(Message::Extended { ext_id: 0, payload })
7192            .await
7193            .unwrap();
7194
7195        // Send bitfield saying we have all pieces (act as seeder)
7196        let mut bf = Bitfield::new(2);
7197        bf.set(0);
7198        bf.set(1);
7199        framed_write
7200            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
7201            .await
7202            .unwrap();
7203
7204        // Unchoke the torrent
7205        framed_write.send(Message::Unchoke).await.unwrap();
7206
7207        // Count Request messages received within 500ms.
7208        // With 1 KB/s download limit, the bucket only accumulates ~50 bytes
7209        // per 100ms tick, far less than 16 KB needed for a full chunk request.
7210        let mut requests_received = 0u32;
7211        let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
7212        loop {
7213            match tokio::time::timeout(
7214                deadline.saturating_duration_since(tokio::time::Instant::now()),
7215                framed_read.next(),
7216            )
7217            .await
7218            {
7219                Ok(Some(Ok(Message::Request { .. }))) => {
7220                    requests_received += 1;
7221                }
7222                Ok(Some(Ok(_))) => continue,
7223                _ => break,
7224            }
7225        }
7226
7227        let stats = handle.stats().await.unwrap();
7228        assert_eq!(stats.state, TorrentState::Downloading);
7229
7230        // With 1 KB/s download limit and 16 KB chunks, we should see very few
7231        // or no requests within 500ms (budget insufficient for even one chunk)
7232        assert!(
7233            requests_received <= 2,
7234            "with 1 KB/s limit, should get very few requests, got {requests_received}"
7235        );
7236
7237        handle.shutdown().await.unwrap();
7238    }
7239
7240    // ── Smart banning tests (M25) ────────────────────────────────────
7241
7242    #[test]
7243    fn piece_contributor_tracking() {
7244        use std::net::IpAddr;
7245        let mut contributors: HashMap<u32, HashSet<IpAddr>> = HashMap::new();
7246        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
7247        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
7248
7249        contributors.entry(0).or_default().insert(ip1);
7250        contributors.entry(0).or_default().insert(ip2);
7251        assert_eq!(contributors[&0].len(), 2);
7252        assert!(contributors[&0].contains(&ip1));
7253        assert!(contributors[&0].contains(&ip2));
7254
7255        // Clear on verify
7256        contributors.remove(&0);
7257        assert!(!contributors.contains_key(&0));
7258    }
7259
7260    #[test]
7261    fn parole_enter_on_hash_failure() {
7262        use crate::ban::{BanConfig, BanManager, ParoleState};
7263        use std::net::IpAddr;
7264
7265        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
7266        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
7267        let contributors = vec![ip1, ip2];
7268
7269        // Simulate entering parole
7270        let parole = ParoleState {
7271            original_contributors: contributors.into_iter().collect(),
7272            parole_peer: None,
7273        };
7274
7275        assert_eq!(parole.original_contributors.len(), 2);
7276        assert!(parole.original_contributors.contains(&ip1));
7277        assert!(parole.original_contributors.contains(&ip2));
7278        assert!(parole.parole_peer.is_none());
7279    }
7280
7281    #[test]
7282    fn parole_success_strikes_originals() {
7283        use crate::ban::{BanConfig, BanManager, ParoleState};
7284        use std::net::IpAddr;
7285
7286        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
7287        let ip2: IpAddr = "10.0.0.2".parse().unwrap();
7288        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
7289
7290        let mut mgr = BanManager::new(BanConfig {
7291            max_failures: 2,
7292            use_parole: true,
7293        });
7294
7295        let parole = ParoleState {
7296            original_contributors: [ip1, ip2].into_iter().collect(),
7297            parole_peer: Some(parole_ip),
7298        };
7299
7300        // Simulate parole success: strike all originals
7301        for ip in &parole.original_contributors {
7302            mgr.record_strike(*ip);
7303        }
7304
7305        assert_eq!(*mgr.strikes_map().get(&ip1).unwrap(), 1);
7306        assert_eq!(*mgr.strikes_map().get(&ip2).unwrap(), 1);
7307        // Parole peer should not be struck
7308        assert!(!mgr.strikes_map().contains_key(&parole_ip));
7309
7310        // Second strike bans them
7311        for ip in &parole.original_contributors {
7312            mgr.record_strike(*ip);
7313        }
7314        assert!(mgr.is_banned(&ip1));
7315        assert!(mgr.is_banned(&ip2));
7316    }
7317
7318    #[test]
7319    fn parole_failure_strikes_parole_peer() {
7320        use crate::ban::{BanConfig, BanManager, ParoleState};
7321        use std::net::IpAddr;
7322
7323        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
7324        let parole_ip: IpAddr = "10.0.0.3".parse().unwrap();
7325
7326        let mut mgr = BanManager::new(BanConfig {
7327            max_failures: 2,
7328            use_parole: true,
7329        });
7330
7331        let parole = ParoleState {
7332            original_contributors: [ip1].into_iter().collect(),
7333            parole_peer: Some(parole_ip),
7334        };
7335
7336        // Parole failure: strike the parole peer, not originals
7337        if let Some(pp) = parole.parole_peer {
7338            mgr.record_strike(pp);
7339        }
7340
7341        assert_eq!(*mgr.strikes_map().get(&parole_ip).unwrap(), 1);
7342        assert!(!mgr.strikes_map().contains_key(&ip1));
7343    }
7344
7345    #[tokio::test]
7346    async fn banned_peer_rejected_on_connect() {
7347        let data = vec![0xAB; 32768];
7348        let meta = make_test_torrent(&data, 16384);
7349        let storage = make_storage(&data, 16384);
7350        let config = test_config();
7351        let ban_mgr = test_ban_manager();
7352
7353        // Pre-ban an IP
7354        let banned_ip: std::net::IpAddr = "192.168.1.100".parse().unwrap();
7355        ban_mgr.write().ban(banned_ip);
7356
7357        let (atx, amask) = test_alert_channel();
7358        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7359        let handle = TorrentHandle::from_torrent(
7360            meta,
7361            irontide_core::TorrentVersion::V1Only,
7362            None,
7363            dh,
7364            dm,
7365            config,
7366            None,
7367            None,
7368            None,
7369            None,
7370            crate::slot_tuner::SlotTuner::disabled(4),
7371            atx,
7372            amask,
7373            None,
7374            None,
7375            Arc::clone(&ban_mgr),
7376            test_ip_filter(),
7377            Arc::new(Vec::new()),
7378            None,
7379            None,
7380            Arc::new(crate::transport::NetworkFactory::tokio()),
7381            None, // M96: hash_pool
7382        )
7383        .await
7384        .unwrap();
7385
7386        // Add the banned peer — it should be filtered out
7387        handle
7388            .add_peers(
7389                vec![
7390                    SocketAddr::new(banned_ip, 6881),
7391                    "10.0.0.1:6881".parse().unwrap(),
7392                ],
7393                PeerSource::Tracker,
7394            )
7395            .await
7396            .unwrap();
7397
7398        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7399        let stats = handle.stats().await.unwrap();
7400        // Only the non-banned peer should be in available pool (and may have connected)
7401        // The banned one should never appear
7402        assert!(
7403            stats.peers_available + stats.peers_connected <= 1,
7404            "banned peer should not be added: available={}, connected={}",
7405            stats.peers_available,
7406            stats.peers_connected
7407        );
7408
7409        handle.shutdown().await.unwrap();
7410    }
7411
7412    #[test]
7413    fn banned_peer_filtered_from_available() {
7414        use crate::ban::{BanConfig, BanManager};
7415        use std::net::IpAddr;
7416
7417        let banned_ip: IpAddr = "192.168.1.200".parse().unwrap();
7418        let ok_ip: IpAddr = "10.0.0.1".parse().unwrap();
7419
7420        let mgr = BanManager::new(BanConfig::default());
7421        // Not banned yet — both should pass
7422        assert!(!mgr.is_banned(&banned_ip));
7423        assert!(!mgr.is_banned(&ok_ip));
7424
7425        let mut mgr = BanManager::new(BanConfig::default());
7426        mgr.ban(banned_ip);
7427
7428        // Now banned_ip is filtered, ok_ip is not
7429        assert!(mgr.is_banned(&banned_ip));
7430        assert!(!mgr.is_banned(&ok_ip));
7431    }
7432
7433    // ---- M27: Parallel hashing tests ----
7434
7435    #[test]
7436    fn hashing_threads_config_default() {
7437        let s = crate::settings::Settings::default();
7438        let expected = {
7439            let cores = std::thread::available_parallelism()
7440                .map(|n| n.get())
7441                .unwrap_or(4);
7442            (cores / 4).clamp(2, 8)
7443        };
7444        assert_eq!(s.hashing_threads, expected);
7445        let tc = TorrentConfig::default();
7446        assert_eq!(tc.hashing_threads, expected);
7447    }
7448
7449    #[tokio::test]
7450    async fn checking_state_and_progress_alerts() {
7451        use crate::alert::{AlertCategory, AlertKind};
7452
7453        let data = vec![0xEEu8; 65536]; // 4 pieces of 16384
7454        let meta = make_test_torrent(&data, 16384);
7455        let storage = make_seeded_storage(&data, 16384);
7456        let config = test_config();
7457
7458        let (atx, amask) = test_alert_channel();
7459        let mut rx = atx.subscribe();
7460        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7461        let handle = TorrentHandle::from_torrent(
7462            meta,
7463            irontide_core::TorrentVersion::V1Only,
7464            None,
7465            dh,
7466            dm,
7467            config,
7468            None,
7469            None,
7470            None,
7471            None,
7472            crate::slot_tuner::SlotTuner::disabled(4),
7473            atx,
7474            amask,
7475            None,
7476            None,
7477            test_ban_manager(),
7478            test_ip_filter(),
7479            Arc::new(Vec::new()),
7480            None,
7481            None,
7482            Arc::new(crate::transport::NetworkFactory::tokio()),
7483            None, // M96: hash_pool
7484        )
7485        .await
7486        .unwrap();
7487
7488        // Collect alerts for up to 2 seconds
7489        let mut saw_checking = false;
7490        let mut progress_values: Vec<f32> = Vec::new();
7491        let mut saw_checked = false;
7492        let mut checked_have = 0u32;
7493        let mut checked_total = 0u32;
7494
7495        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
7496        while tokio::time::Instant::now() < deadline {
7497            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
7498                Ok(Ok(alert)) => match alert.kind {
7499                    AlertKind::StateChanged {
7500                        new_state: TorrentState::Checking,
7501                        ..
7502                    } => {
7503                        saw_checking = true;
7504                    }
7505                    AlertKind::CheckingProgress { progress, .. } => {
7506                        progress_values.push(progress);
7507                    }
7508                    AlertKind::TorrentChecked {
7509                        pieces_have,
7510                        pieces_total,
7511                        ..
7512                    } => {
7513                        saw_checked = true;
7514                        checked_have = pieces_have;
7515                        checked_total = pieces_total;
7516                        break;
7517                    }
7518                    _ => {}
7519                },
7520                _ => break,
7521            }
7522        }
7523
7524        assert!(saw_checking, "should have seen StateChanged → Checking");
7525        assert!(
7526            !progress_values.is_empty(),
7527            "should have seen CheckingProgress alerts"
7528        );
7529        // Progress should be monotonically increasing
7530        for w in progress_values.windows(2) {
7531            assert!(
7532                w[1] >= w[0],
7533                "progress should be monotonically increasing: {} < {}",
7534                w[0],
7535                w[1]
7536            );
7537        }
7538        assert!(saw_checked, "should have seen TorrentChecked");
7539        assert_eq!(checked_have, 4);
7540        assert_eq!(checked_total, 4);
7541
7542        // Final state should be Seeding (all pieces valid)
7543        tokio::time::sleep(Duration::from_millis(50)).await;
7544        let stats = handle.stats().await.unwrap();
7545        assert_eq!(stats.state, TorrentState::Seeding);
7546
7547        handle.shutdown().await.unwrap();
7548    }
7549
7550    #[tokio::test]
7551    async fn checking_progress_in_stats() {
7552        // When not in Checking state, checking_progress should be 0.0
7553        let data = vec![0xAB; 32768];
7554        let meta = make_test_torrent(&data, 16384);
7555        let storage = make_storage(&data, 16384);
7556        let config = test_config();
7557
7558        let (atx, amask) = test_alert_channel();
7559        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7560        let handle = TorrentHandle::from_torrent(
7561            meta,
7562            irontide_core::TorrentVersion::V1Only,
7563            None,
7564            dh,
7565            dm,
7566            config,
7567            None,
7568            None,
7569            None,
7570            None,
7571            crate::slot_tuner::SlotTuner::disabled(4),
7572            atx,
7573            amask,
7574            None,
7575            None,
7576            test_ban_manager(),
7577            test_ip_filter(),
7578            Arc::new(Vec::new()),
7579            None,
7580            None,
7581            Arc::new(crate::transport::NetworkFactory::tokio()),
7582            None, // M96: hash_pool
7583        )
7584        .await
7585        .unwrap();
7586
7587        // Give actor time to finish checking (no valid pieces → Downloading)
7588        tokio::time::sleep(Duration::from_millis(100)).await;
7589
7590        let stats = handle.stats().await.unwrap();
7591        assert_eq!(stats.state, TorrentState::Downloading);
7592        assert_eq!(
7593            stats.checking_progress, 0.0,
7594            "checking_progress should be 0.0 when not checking"
7595        );
7596
7597        handle.shutdown().await.unwrap();
7598    }
7599
7600    #[tokio::test]
7601    async fn verify_pieces_partial_data() {
7602        use crate::alert::AlertKind;
7603
7604        // 4 pieces, only first 2 have valid data
7605        let data = vec![0xCCu8; 65536]; // 4 pieces × 16384
7606        let meta = make_test_torrent(&data, 16384);
7607
7608        // Create storage and only write valid data for pieces 0 and 1
7609        let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
7610        let storage = Arc::new(MemoryStorage::new(lengths.clone()));
7611        for p in 0..2u32 {
7612            let offset = lengths.piece_offset(p) as usize;
7613            let size = lengths.piece_size(p) as usize;
7614            storage
7615                .write_chunk(p, 0, &data[offset..offset + size])
7616                .unwrap();
7617        }
7618        // Pieces 2 and 3 have no data (zeros) — won't match hash
7619
7620        let config = test_config();
7621        let (atx, amask) = test_alert_channel();
7622        let mut rx = atx.subscribe();
7623        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7624        let handle = TorrentHandle::from_torrent(
7625            meta,
7626            irontide_core::TorrentVersion::V1Only,
7627            None,
7628            dh,
7629            dm,
7630            config,
7631            None,
7632            None,
7633            None,
7634            None,
7635            crate::slot_tuner::SlotTuner::disabled(4),
7636            atx,
7637            amask,
7638            None,
7639            None,
7640            test_ban_manager(),
7641            test_ip_filter(),
7642            Arc::new(Vec::new()),
7643            None,
7644            None,
7645            Arc::new(crate::transport::NetworkFactory::tokio()),
7646            None, // M96: hash_pool
7647        )
7648        .await
7649        .unwrap();
7650
7651        // Wait for TorrentChecked alert
7652        let mut checked_have = 0u32;
7653        let mut checked_total = 0u32;
7654        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
7655        while tokio::time::Instant::now() < deadline {
7656            match tokio::time::timeout(Duration::from_millis(200), rx.recv()).await {
7657                Ok(Ok(alert)) => {
7658                    if let AlertKind::TorrentChecked {
7659                        pieces_have,
7660                        pieces_total,
7661                        ..
7662                    } = alert.kind
7663                    {
7664                        checked_have = pieces_have;
7665                        checked_total = pieces_total;
7666                        break;
7667                    }
7668                }
7669                _ => break,
7670            }
7671        }
7672
7673        assert_eq!(checked_have, 2, "only 2 pieces should be valid");
7674        assert_eq!(checked_total, 4);
7675
7676        // Final state should be Downloading (partial)
7677        tokio::time::sleep(Duration::from_millis(50)).await;
7678        let stats = handle.stats().await.unwrap();
7679        assert_eq!(stats.state, TorrentState::Downloading);
7680        assert_eq!(stats.pieces_have, 2);
7681        assert_eq!(stats.pieces_total, 4);
7682
7683        handle.shutdown().await.unwrap();
7684    }
7685
7686    // ---- M29: IP filter integration tests ----
7687
7688    #[tokio::test]
7689    async fn ip_filter_blocks_peers_in_handle_add_peers() {
7690        let data = vec![0xCD; 32768];
7691        let meta = make_test_torrent(&data, 16384);
7692        let storage = make_storage(&data, 16384);
7693        let config = test_config();
7694
7695        // Create an IP filter that blocks 203.0.113.0/24 (TEST-NET-3, public range)
7696        let ip_filter = {
7697            let mut f = crate::ip_filter::IpFilter::new();
7698            f.add_rule(
7699                "203.0.113.0".parse().unwrap(),
7700                "203.0.113.255".parse().unwrap(),
7701                1,
7702            );
7703            Arc::new(parking_lot::RwLock::new(f))
7704        };
7705
7706        let (atx, amask) = test_alert_channel();
7707        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7708        let handle = TorrentHandle::from_torrent(
7709            meta,
7710            irontide_core::TorrentVersion::V1Only,
7711            None,
7712            dh,
7713            dm,
7714            config,
7715            None,
7716            None,
7717            None,
7718            None,
7719            crate::slot_tuner::SlotTuner::disabled(4),
7720            atx,
7721            amask,
7722            None,
7723            None,
7724            test_ban_manager(),
7725            Arc::clone(&ip_filter),
7726            Arc::new(Vec::new()),
7727            None,
7728            None,
7729            Arc::new(crate::transport::NetworkFactory::tokio()),
7730            None, // M96: hash_pool
7731        )
7732        .await
7733        .unwrap();
7734
7735        // Add peers: one blocked (public IP in TEST-NET-3), one allowed (different public IP)
7736        let blocked_addr: SocketAddr = "203.0.113.42:6881".parse().unwrap();
7737        let allowed_addr: SocketAddr = "198.51.100.1:6881".parse().unwrap();
7738        handle
7739            .add_peers(vec![blocked_addr, allowed_addr], PeerSource::Tracker)
7740            .await
7741            .unwrap();
7742
7743        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7744        let stats = handle.stats().await.unwrap();
7745        // Only the allowed peer should be in the pool
7746        assert!(
7747            stats.peers_available + stats.peers_connected <= 1,
7748            "blocked peer should not be added: available={}, connected={}",
7749            stats.peers_available,
7750            stats.peers_connected
7751        );
7752
7753        handle.shutdown().await.unwrap();
7754    }
7755
7756    #[tokio::test]
7757    async fn set_ip_filter_replaces_filter_and_blocks_new_ip() {
7758        // Test that updating the shared IP filter takes effect for new peer additions.
7759        // Use public IPs (TEST-NET ranges) since local networks are always exempt.
7760        let data = vec![0xCD; 32768];
7761        let meta = make_test_torrent(&data, 16384);
7762        let storage = make_storage(&data, 16384);
7763        let config = test_config();
7764
7765        // Start with empty filter (everything allowed)
7766        let ip_filter: crate::session::SharedIpFilter =
7767            Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
7768
7769        let (atx, amask) = test_alert_channel();
7770        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7771        let handle = TorrentHandle::from_torrent(
7772            meta,
7773            irontide_core::TorrentVersion::V1Only,
7774            None,
7775            dh,
7776            dm,
7777            config,
7778            None,
7779            None,
7780            None,
7781            None,
7782            crate::slot_tuner::SlotTuner::disabled(4),
7783            atx,
7784            amask,
7785            None,
7786            None,
7787            test_ban_manager(),
7788            Arc::clone(&ip_filter),
7789            Arc::new(Vec::new()),
7790            None,
7791            None,
7792            Arc::new(crate::transport::NetworkFactory::tokio()),
7793            None, // M96: hash_pool
7794        )
7795        .await
7796        .unwrap();
7797
7798        // Initially, peers are allowed by the IP filter.
7799        // Use a local listener so the connection succeeds and the peer stays known.
7800        let _listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
7801        let local_addr = _listener.local_addr().unwrap();
7802        handle
7803            .add_peers(vec![local_addr], PeerSource::Tracker)
7804            .await
7805            .unwrap();
7806        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7807        let stats = handle.stats().await.unwrap();
7808        assert!(
7809            stats.peers_available + stats.peers_connected >= 1,
7810            "peer should be allowed initially"
7811        );
7812        handle.shutdown().await.unwrap();
7813
7814        // Now update the shared filter to block that IP range
7815        {
7816            let mut f = ip_filter.write();
7817            f.add_rule(
7818                "198.51.100.0".parse().unwrap(),
7819                "198.51.100.255".parse().unwrap(),
7820                1,
7821            );
7822        }
7823
7824        // Verify the filter is updated (public IP, so is_blocked applies)
7825        assert!(ip_filter.read().is_blocked("198.51.100.1".parse().unwrap()));
7826        // Verify a different public IP is still allowed
7827        assert!(!ip_filter.read().is_blocked("203.0.113.1".parse().unwrap()));
7828    }
7829
7830    #[test]
7831    fn relocate_files_moves_and_cleans_up() {
7832        let tmp = std::env::temp_dir().join(format!("torrent_relocate_{}", std::process::id()));
7833        let src = tmp.join("src");
7834        let dst = tmp.join("dst");
7835
7836        // Create source files mimicking multi-file torrent layout:
7837        // TorrentName/subdir/file1.txt
7838        // TorrentName/file2.txt
7839        let subdir = src.join("TorrentName").join("subdir");
7840        std::fs::create_dir_all(&subdir).unwrap();
7841        std::fs::write(subdir.join("file1.txt"), b"hello").unwrap();
7842        std::fs::write(src.join("TorrentName").join("file2.txt"), b"world").unwrap();
7843
7844        let file_paths = vec![
7845            std::path::PathBuf::from("TorrentName/subdir/file1.txt"),
7846            std::path::PathBuf::from("TorrentName/file2.txt"),
7847        ];
7848
7849        relocate_files(&src, &dst, &file_paths).unwrap();
7850
7851        // Destination should have both files
7852        assert_eq!(
7853            std::fs::read_to_string(dst.join("TorrentName/subdir/file1.txt")).unwrap(),
7854            "hello"
7855        );
7856        assert_eq!(
7857            std::fs::read_to_string(dst.join("TorrentName/file2.txt")).unwrap(),
7858            "world"
7859        );
7860
7861        // Source directory should be cleaned up (empty dirs removed)
7862        assert!(!src.join("TorrentName").join("subdir").exists());
7863        assert!(!src.join("TorrentName").exists());
7864
7865        // Cleanup
7866        let _ = std::fs::remove_dir_all(&tmp);
7867    }
7868
7869    #[test]
7870    fn relocate_files_skips_missing() {
7871        let tmp =
7872            std::env::temp_dir().join(format!("torrent_relocate_skip_{}", std::process::id()));
7873        let src = tmp.join("src");
7874        let dst = tmp.join("dst");
7875        std::fs::create_dir_all(&src).unwrap();
7876
7877        // File doesn't exist — should be skipped without error
7878        let file_paths = vec![std::path::PathBuf::from("nonexistent.txt")];
7879        relocate_files(&src, &dst, &file_paths).unwrap();
7880
7881        assert!(!dst.join("nonexistent.txt").exists());
7882
7883        let _ = std::fs::remove_dir_all(&tmp);
7884    }
7885
7886    // ---- Test: force_recheck transitions through Checking state ----
7887
7888    #[tokio::test]
7889    async fn force_recheck_transitions_to_checking() {
7890        let data = vec![0xDDu8; 32768]; // 2 pieces
7891        let meta = make_test_torrent(&data, 16384);
7892        let storage = make_seeded_storage(&data, 16384);
7893        let config = test_config();
7894
7895        let (atx, amask) = test_alert_channel();
7896        let mut arx = atx.subscribe();
7897        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7898        let handle = TorrentHandle::from_torrent(
7899            meta,
7900            irontide_core::TorrentVersion::V1Only,
7901            None,
7902            dh,
7903            dm,
7904            config,
7905            None,
7906            None,
7907            None,
7908            None,
7909            crate::slot_tuner::SlotTuner::disabled(4),
7910            atx,
7911            amask,
7912            None,
7913            None,
7914            test_ban_manager(),
7915            test_ip_filter(),
7916            Arc::new(Vec::new()),
7917            None,
7918            None,
7919            Arc::new(crate::transport::NetworkFactory::tokio()),
7920            None, // M96: hash_pool
7921        )
7922        .await
7923        .unwrap();
7924
7925        // Wait for initial verification to complete (should become Seeding)
7926        tokio::time::sleep(Duration::from_millis(100)).await;
7927        let stats = handle.stats().await.unwrap();
7928        assert_eq!(stats.state, TorrentState::Seeding, "should start as seeder");
7929
7930        // Drain any existing alerts
7931        while arx.try_recv().is_ok() {}
7932
7933        // Force recheck
7934        handle.force_recheck().await.unwrap();
7935
7936        // After force_recheck returns, look for a StateChanged alert that
7937        // went through Checking (the transition_state fires it)
7938        let mut saw_checking = false;
7939        while let Ok(alert) = arx.try_recv() {
7940            if let crate::alert::AlertKind::StateChanged { new_state, .. } = alert.kind {
7941                if new_state == TorrentState::Checking {
7942                    saw_checking = true;
7943                }
7944            }
7945        }
7946        assert!(
7947            saw_checking,
7948            "should have transitioned through Checking state"
7949        );
7950
7951        handle.shutdown().await.unwrap();
7952    }
7953
7954    // ---- Test: force_recheck completes with correct state ----
7955
7956    #[tokio::test]
7957    async fn force_recheck_completes() {
7958        let data = vec![0xEEu8; 32768]; // 2 pieces
7959        let meta = make_test_torrent(&data, 16384);
7960        let storage = make_seeded_storage(&data, 16384);
7961        let config = test_config();
7962
7963        let (atx, amask) = test_alert_channel();
7964        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
7965        let handle = TorrentHandle::from_torrent(
7966            meta,
7967            irontide_core::TorrentVersion::V1Only,
7968            None,
7969            dh,
7970            dm,
7971            config,
7972            None,
7973            None,
7974            None,
7975            None,
7976            crate::slot_tuner::SlotTuner::disabled(4),
7977            atx,
7978            amask,
7979            None,
7980            None,
7981            test_ban_manager(),
7982            test_ip_filter(),
7983            Arc::new(Vec::new()),
7984            None,
7985            None,
7986            Arc::new(crate::transport::NetworkFactory::tokio()),
7987            None, // M96: hash_pool
7988        )
7989        .await
7990        .unwrap();
7991
7992        // Wait for initial verification
7993        tokio::time::sleep(Duration::from_millis(100)).await;
7994        let stats = handle.stats().await.unwrap();
7995        assert_eq!(stats.state, TorrentState::Seeding);
7996        assert_eq!(stats.pieces_have, 2);
7997
7998        // Force recheck — should re-verify all pieces and return to Seeding
7999        handle.force_recheck().await.unwrap();
8000
8001        let stats = handle.stats().await.unwrap();
8002        assert_eq!(
8003            stats.state,
8004            TorrentState::Seeding,
8005            "should return to Seeding after recheck"
8006        );
8007        assert_eq!(stats.pieces_have, 2, "all pieces should still be verified");
8008
8009        handle.shutdown().await.unwrap();
8010    }
8011
8012    // ---- Test: rename_file succeeds with valid index ----
8013
8014    #[tokio::test]
8015    async fn rename_file_succeeds() {
8016        // Create a real file on disk that we can rename
8017        let tmp = std::env::temp_dir().join(format!("torrent_rename_{}", std::process::id()));
8018        std::fs::create_dir_all(&tmp).unwrap();
8019
8020        let data = vec![0xFFu8; 16384]; // 1 piece
8021        let meta = make_test_torrent(&data, 16384);
8022        let storage = make_seeded_storage(&data, 16384);
8023
8024        // The single-file torrent has name "test", so file path is "test"
8025        // Create the actual file on disk at download_dir/test
8026        std::fs::write(tmp.join("test"), &data).unwrap();
8027
8028        let mut config = test_config();
8029        config.download_dir = tmp.clone();
8030
8031        let (atx, amask) = test_alert_channel();
8032        let mut arx = atx.subscribe();
8033        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8034        let handle = TorrentHandle::from_torrent(
8035            meta,
8036            irontide_core::TorrentVersion::V1Only,
8037            None,
8038            dh,
8039            dm,
8040            config,
8041            None,
8042            None,
8043            None,
8044            None,
8045            crate::slot_tuner::SlotTuner::disabled(4),
8046            atx,
8047            amask,
8048            None,
8049            None,
8050            test_ban_manager(),
8051            test_ip_filter(),
8052            Arc::new(Vec::new()),
8053            None,
8054            None,
8055            Arc::new(crate::transport::NetworkFactory::tokio()),
8056            None, // M96: hash_pool
8057        )
8058        .await
8059        .unwrap();
8060
8061        // Wait for initial verification
8062        tokio::time::sleep(Duration::from_millis(100)).await;
8063
8064        // Drain existing alerts
8065        while arx.try_recv().is_ok() {}
8066
8067        // Rename file 0 to "test_renamed"
8068        handle.rename_file(0, "test_renamed".into()).await.unwrap();
8069
8070        // Check that the old file is gone and new file exists
8071        assert!(!tmp.join("test").exists(), "old file should be removed");
8072        assert!(tmp.join("test_renamed").exists(), "new file should exist");
8073
8074        // Check that FileRenamed alert was fired
8075        let mut saw_renamed = false;
8076        while let Ok(alert) = arx.try_recv() {
8077            if let AlertKind::FileRenamed { index, .. } = alert.kind {
8078                assert_eq!(index, 0);
8079                saw_renamed = true;
8080            }
8081        }
8082        assert!(saw_renamed, "should have received FileRenamed alert");
8083
8084        handle.shutdown().await.unwrap();
8085        let _ = std::fs::remove_dir_all(&tmp);
8086    }
8087
8088    // ---- Test: rename_file with invalid index returns error ----
8089
8090    #[tokio::test]
8091    async fn rename_file_invalid_index_errors() {
8092        let data = vec![0xCCu8; 16384]; // 1 piece, single-file torrent
8093        let meta = make_test_torrent(&data, 16384);
8094        let storage = make_seeded_storage(&data, 16384);
8095        let config = test_config();
8096
8097        let (atx, amask) = test_alert_channel();
8098        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8099        let handle = TorrentHandle::from_torrent(
8100            meta,
8101            irontide_core::TorrentVersion::V1Only,
8102            None,
8103            dh,
8104            dm,
8105            config,
8106            None,
8107            None,
8108            None,
8109            None,
8110            crate::slot_tuner::SlotTuner::disabled(4),
8111            atx,
8112            amask,
8113            None,
8114            None,
8115            test_ban_manager(),
8116            test_ip_filter(),
8117            Arc::new(Vec::new()),
8118            None,
8119            None,
8120            Arc::new(crate::transport::NetworkFactory::tokio()),
8121            None, // M96: hash_pool
8122        )
8123        .await
8124        .unwrap();
8125
8126        // Wait for initial verification
8127        tokio::time::sleep(Duration::from_millis(100)).await;
8128
8129        // Try to rename file index 99 (out of range)
8130        let result = handle.rename_file(99, "bad".into()).await;
8131        assert!(result.is_err(), "should fail for out-of-range file index");
8132
8133        handle.shutdown().await.unwrap();
8134    }
8135
8136    // ---- Test: FileCompleted alert fires when all pieces of a file are verified ----
8137
8138    #[tokio::test]
8139    async fn file_completed_alert_fires() {
8140        let data = vec![0xBBu8; 32768]; // 2 pieces
8141        let meta = make_test_torrent(&data, 16384);
8142        let storage = make_seeded_storage(&data, 16384);
8143        let config = test_config();
8144
8145        let (atx, amask) = test_alert_channel();
8146        let mut arx = atx.subscribe();
8147        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8148        let handle = TorrentHandle::from_torrent(
8149            meta,
8150            irontide_core::TorrentVersion::V1Only,
8151            None,
8152            dh,
8153            dm,
8154            config,
8155            None,
8156            None,
8157            None,
8158            None,
8159            crate::slot_tuner::SlotTuner::disabled(4),
8160            atx,
8161            amask,
8162            None,
8163            None,
8164            test_ban_manager(),
8165            test_ip_filter(),
8166            Arc::new(Vec::new()),
8167            None,
8168            None,
8169            Arc::new(crate::transport::NetworkFactory::tokio()),
8170            None, // M96: hash_pool
8171        )
8172        .await
8173        .unwrap();
8174
8175        // Wait for initial verification (seeded storage => all pieces verify)
8176        tokio::time::sleep(Duration::from_millis(200)).await;
8177
8178        // Should have received FileCompleted alert for the single file
8179        let mut saw_file_completed = false;
8180        while let Ok(alert) = arx.try_recv() {
8181            if let AlertKind::FileCompleted { file_index, .. } = alert.kind {
8182                assert_eq!(file_index, 0, "should be file index 0");
8183                saw_file_completed = true;
8184            }
8185        }
8186        assert!(
8187            saw_file_completed,
8188            "should have received FileCompleted alert"
8189        );
8190
8191        handle.shutdown().await.unwrap();
8192    }
8193
8194    // ---- Test: MetadataFailed alert fires (unit test on AlertKind) ----
8195
8196    #[test]
8197    fn metadata_failed_alert_fires() {
8198        // Test that MetadataFailed alert has the correct category
8199        let info_hash = Id20::from([0u8; 20]);
8200        let alert = crate::alert::Alert::new(AlertKind::MetadataFailed { info_hash });
8201        assert!(
8202            alert
8203                .category()
8204                .contains(crate::alert::AlertCategory::STATUS),
8205            "MetadataFailed should have STATUS category"
8206        );
8207        assert!(
8208            alert
8209                .category()
8210                .contains(crate::alert::AlertCategory::ERROR),
8211            "MetadataFailed should have ERROR category"
8212        );
8213
8214        // Verify it can be posted through the alert system
8215        let (tx, mut rx) = broadcast::channel(16);
8216        let mask = Arc::new(AtomicU32::new(crate::alert::AlertCategory::ALL.bits()));
8217        post_alert(&tx, &mask, AlertKind::MetadataFailed { info_hash });
8218        let received = rx.try_recv().expect("should receive MetadataFailed alert");
8219        assert!(matches!(received.kind, AlertKind::MetadataFailed { .. }));
8220    }
8221
8222    // ---- Test: set_max_connections persists ----
8223
8224    #[tokio::test]
8225    async fn set_max_connections_persists() {
8226        let data = vec![0xAB; 32768];
8227        let meta = make_test_torrent(&data, 16384);
8228        let storage = make_storage(&data, 16384);
8229        let config = test_config();
8230
8231        let (atx, amask) = test_alert_channel();
8232        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8233        let handle = TorrentHandle::from_torrent(
8234            meta,
8235            irontide_core::TorrentVersion::V1Only,
8236            None,
8237            dh,
8238            dm,
8239            config,
8240            None,
8241            None,
8242            None,
8243            None,
8244            crate::slot_tuner::SlotTuner::disabled(4),
8245            atx,
8246            amask,
8247            None,
8248            None,
8249            test_ban_manager(),
8250            test_ip_filter(),
8251            Arc::new(Vec::new()),
8252            None,
8253            None,
8254            Arc::new(crate::transport::NetworkFactory::tokio()),
8255            None, // M96: hash_pool
8256        )
8257        .await
8258        .unwrap();
8259
8260        // Set max_connections to 10
8261        handle.set_max_connections(10).await.unwrap();
8262        let val = handle.max_connections().await.unwrap();
8263        assert_eq!(val, 10);
8264
8265        // Update to a different value
8266        handle.set_max_connections(25).await.unwrap();
8267        let val = handle.max_connections().await.unwrap();
8268        assert_eq!(val, 25);
8269
8270        // Verify stats reflect the override
8271        let stats = handle.stats().await.unwrap();
8272        assert_eq!(stats.connections_limit, 25);
8273
8274        handle.shutdown().await.unwrap();
8275    }
8276
8277    // ---- Test: max_connections default is 0 (use config.max_peers) ----
8278
8279    #[tokio::test]
8280    async fn max_connections_default() {
8281        let data = vec![0xAB; 32768];
8282        let meta = make_test_torrent(&data, 16384);
8283        let storage = make_storage(&data, 16384);
8284        let config = test_config();
8285        let expected_default = config.max_peers;
8286
8287        let (atx, amask) = test_alert_channel();
8288        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8289        let handle = TorrentHandle::from_torrent(
8290            meta,
8291            irontide_core::TorrentVersion::V1Only,
8292            None,
8293            dh,
8294            dm,
8295            config,
8296            None,
8297            None,
8298            None,
8299            None,
8300            crate::slot_tuner::SlotTuner::disabled(4),
8301            atx,
8302            amask,
8303            None,
8304            None,
8305            test_ban_manager(),
8306            test_ip_filter(),
8307            Arc::new(Vec::new()),
8308            None,
8309            None,
8310            Arc::new(crate::transport::NetworkFactory::tokio()),
8311            None, // M96: hash_pool
8312        )
8313        .await
8314        .unwrap();
8315
8316        // Default max_connections should be 0
8317        let val = handle.max_connections().await.unwrap();
8318        assert_eq!(val, 0);
8319
8320        // Stats should show config.max_peers as the effective limit
8321        let stats = handle.stats().await.unwrap();
8322        assert_eq!(stats.connections_limit, expected_default);
8323
8324        handle.shutdown().await.unwrap();
8325    }
8326
8327    // ---- Test: set_max_uploads round trip ----
8328
8329    #[tokio::test]
8330    async fn set_max_uploads_round_trip() {
8331        let data = vec![0xAB; 32768];
8332        let meta = make_test_torrent(&data, 16384);
8333        let storage = make_storage(&data, 16384);
8334        let config = test_config();
8335
8336        let (atx, amask) = test_alert_channel();
8337        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8338        let handle = TorrentHandle::from_torrent(
8339            meta,
8340            irontide_core::TorrentVersion::V1Only,
8341            None,
8342            dh,
8343            dm,
8344            config,
8345            None,
8346            None,
8347            None,
8348            None,
8349            crate::slot_tuner::SlotTuner::disabled(4),
8350            atx,
8351            amask,
8352            None,
8353            None,
8354            test_ban_manager(),
8355            test_ip_filter(),
8356            Arc::new(Vec::new()),
8357            None,
8358            None,
8359            Arc::new(crate::transport::NetworkFactory::tokio()),
8360            None, // M96: hash_pool
8361        )
8362        .await
8363        .unwrap();
8364
8365        // Set max_uploads to 8
8366        handle.set_max_uploads(8).await.unwrap();
8367        let val = handle.max_uploads().await.unwrap();
8368        assert_eq!(val, 8);
8369
8370        // Verify stats uploads_limit reflects the new value
8371        let stats = handle.stats().await.unwrap();
8372        assert_eq!(stats.uploads_limit, 8);
8373
8374        handle.shutdown().await.unwrap();
8375    }
8376
8377    // ---- Test: ExternalIpDetected alert fires ----
8378
8379    #[tokio::test]
8380    async fn external_ip_detected_alert() {
8381        let data = vec![0xAB; 32768];
8382        let meta = make_test_torrent(&data, 16384);
8383        let info_hash = meta.info_hash;
8384        let storage = make_storage(&data, 16384);
8385        let config = test_config();
8386
8387        let (atx, amask) = test_alert_channel();
8388        let mut arx = atx.subscribe();
8389        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8390        let handle = TorrentHandle::from_torrent(
8391            meta,
8392            irontide_core::TorrentVersion::V1Only,
8393            None,
8394            dh,
8395            dm,
8396            config,
8397            None,
8398            None,
8399            None,
8400            None,
8401            crate::slot_tuner::SlotTuner::disabled(4),
8402            atx,
8403            amask,
8404            None,
8405            None,
8406            test_ban_manager(),
8407            test_ip_filter(),
8408            Arc::new(Vec::new()),
8409            None,
8410            None,
8411            Arc::new(crate::transport::NetworkFactory::tokio()),
8412            None, // M96: hash_pool
8413        )
8414        .await
8415        .unwrap();
8416
8417        // Drain any initial alerts
8418        while arx.try_recv().is_ok() {}
8419
8420        // Send UpdateExternalIp command
8421        let test_ip: std::net::IpAddr = "203.0.113.42".parse().unwrap();
8422        handle
8423            .cmd_tx
8424            .send(TorrentCommand::UpdateExternalIp { ip: test_ip })
8425            .await
8426            .unwrap();
8427
8428        // Wait for the actor to process
8429        tokio::time::sleep(Duration::from_millis(50)).await;
8430
8431        // Check for ExternalIpDetected alert
8432        let mut saw_alert = false;
8433        while let Ok(alert) = arx.try_recv() {
8434            if let AlertKind::ExternalIpDetected { ip } = alert.kind {
8435                assert_eq!(ip, test_ip);
8436                saw_alert = true;
8437            }
8438        }
8439        assert!(saw_alert, "should have received ExternalIpDetected alert");
8440
8441        handle.shutdown().await.unwrap();
8442    }
8443
8444    // ---- Test: get_peer_info returns connected peers ----
8445
8446    #[tokio::test]
8447    async fn get_peer_info_returns_connected_peers() {
8448        let data = vec![0xAB; 65536]; // 64 KiB
8449        let meta = make_test_torrent(&data, 16384); // 4 pieces
8450        let storage = make_storage(&data, 16384);
8451        let config = test_config();
8452
8453        let (atx, amask) = test_alert_channel();
8454        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8455        let handle = TorrentHandle::from_torrent(
8456            meta.clone(),
8457            irontide_core::TorrentVersion::V1Only,
8458            None,
8459            dh,
8460            dm,
8461            config,
8462            None,
8463            None,
8464            None,
8465            None,
8466            crate::slot_tuner::SlotTuner::disabled(4),
8467            atx,
8468            amask,
8469            None,
8470            None,
8471            test_ban_manager(),
8472            test_ip_filter(),
8473            Arc::new(Vec::new()),
8474            None,
8475            None,
8476            Arc::new(crate::transport::NetworkFactory::tokio()),
8477            None, // M96: hash_pool
8478        )
8479        .await
8480        .unwrap();
8481
8482        // Set up a fake peer via TCP handshake
8483        let stats = handle.stats().await.unwrap();
8484        let listen_port = stats.peers_connected; // Initially 0
8485
8486        // Add a peer to the available pool and let the actor connect
8487        let peer_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
8488        let peer_addr = peer_listener.local_addr().unwrap();
8489
8490        handle
8491            .add_peers(vec![peer_addr], PeerSource::Tracker)
8492            .await
8493            .unwrap();
8494
8495        // Accept the connection and complete the handshake
8496        let accept_timeout =
8497            tokio::time::timeout(Duration::from_secs(2), peer_listener.accept()).await;
8498        if let Ok(Ok((mut stream, _))) = accept_timeout {
8499            // Read handshake
8500            let mut hs_buf = [0u8; HANDSHAKE_SIZE];
8501            if tokio::time::timeout(Duration::from_millis(500), stream.read_exact(&mut hs_buf))
8502                .await
8503                .is_ok()
8504            {
8505                // Send back handshake
8506                let hs = Handshake::new(meta.info_hash, Id20::from([0xBB; 20]));
8507                let hs_bytes = hs.to_bytes();
8508                let _ = stream.write_all(&hs_bytes).await;
8509
8510                // Give the actor time to register the peer
8511                tokio::time::sleep(Duration::from_millis(200)).await;
8512
8513                // Now query peer info
8514                let peer_info = handle.get_peer_info().await.unwrap();
8515                // We should have at least one peer (the one we just handshaked)
8516                if !peer_info.is_empty() {
8517                    let p = &peer_info[0];
8518                    // Verify default choking/interested state
8519                    assert!(p.peer_choking, "peer should be choking us initially");
8520                    // M107: we unconditionally unchoke on connect, so am_choking starts false
8521                    assert!(
8522                        !p.am_choking,
8523                        "we should not be choking peer after connect (M107 unconditional unchoke)"
8524                    );
8525                    assert!(
8526                        !p.peer_interested,
8527                        "peer should not be interested initially"
8528                    );
8529                    assert_eq!(p.num_pieces, 0);
8530                    assert_eq!(p.source, PeerSource::Tracker);
8531                }
8532            }
8533        }
8534        // Even if handshake timing fails, at least verify the API works
8535        let _ = handle.get_peer_info().await.unwrap();
8536        assert_eq!(listen_port, 0); // sanity: initially had no peers
8537
8538        handle.shutdown().await.unwrap();
8539    }
8540
8541    // ---- Test: get_peer_info empty when no peers ----
8542
8543    #[tokio::test]
8544    async fn get_peer_info_empty_when_no_peers() {
8545        let data = vec![0xAB; 32768];
8546        let meta = make_test_torrent(&data, 16384);
8547        let storage = make_storage(&data, 16384);
8548        let config = test_config();
8549
8550        let (atx, amask) = test_alert_channel();
8551        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8552        let handle = TorrentHandle::from_torrent(
8553            meta,
8554            irontide_core::TorrentVersion::V1Only,
8555            None,
8556            dh,
8557            dm,
8558            config,
8559            None,
8560            None,
8561            None,
8562            None,
8563            crate::slot_tuner::SlotTuner::disabled(4),
8564            atx,
8565            amask,
8566            None,
8567            None,
8568            test_ban_manager(),
8569            test_ip_filter(),
8570            Arc::new(Vec::new()),
8571            None,
8572            None,
8573            Arc::new(crate::transport::NetworkFactory::tokio()),
8574            None, // M96: hash_pool
8575        )
8576        .await
8577        .unwrap();
8578
8579        let peer_info = handle.get_peer_info().await.unwrap();
8580        assert!(peer_info.is_empty(), "should have no peers initially");
8581
8582        handle.shutdown().await.unwrap();
8583    }
8584
8585    // ---- Test: get_download_queue empty initially ----
8586
8587    #[tokio::test]
8588    async fn get_download_queue_empty_initially() {
8589        let data = vec![0xAB; 32768];
8590        let meta = make_test_torrent(&data, 16384);
8591        let storage = make_storage(&data, 16384);
8592        let config = test_config();
8593
8594        let (atx, amask) = test_alert_channel();
8595        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8596        let handle = TorrentHandle::from_torrent(
8597            meta,
8598            irontide_core::TorrentVersion::V1Only,
8599            None,
8600            dh,
8601            dm,
8602            config,
8603            None,
8604            None,
8605            None,
8606            None,
8607            crate::slot_tuner::SlotTuner::disabled(4),
8608            atx,
8609            amask,
8610            None,
8611            None,
8612            test_ban_manager(),
8613            test_ip_filter(),
8614            Arc::new(Vec::new()),
8615            None,
8616            None,
8617            Arc::new(crate::transport::NetworkFactory::tokio()),
8618            None, // M96: hash_pool
8619        )
8620        .await
8621        .unwrap();
8622
8623        let queue = handle.get_download_queue().await.unwrap();
8624        assert!(
8625            queue.is_empty(),
8626            "download queue should be empty with no active downloads"
8627        );
8628
8629        handle.shutdown().await.unwrap();
8630    }
8631
8632    // ---- Test: have_piece false initially ----
8633
8634    #[tokio::test]
8635    async fn have_piece_false_initially() {
8636        let data = vec![0xAB; 32768]; // 32 KiB = 2 pieces
8637        let meta = make_test_torrent(&data, 16384);
8638        let storage = make_storage(&data, 16384);
8639        let config = test_config();
8640
8641        let (atx, amask) = test_alert_channel();
8642        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8643        let handle = TorrentHandle::from_torrent(
8644            meta,
8645            irontide_core::TorrentVersion::V1Only,
8646            None,
8647            dh,
8648            dm,
8649            config,
8650            None,
8651            None,
8652            None,
8653            None,
8654            crate::slot_tuner::SlotTuner::disabled(4),
8655            atx,
8656            amask,
8657            None,
8658            None,
8659            test_ban_manager(),
8660            test_ip_filter(),
8661            Arc::new(Vec::new()),
8662            None,
8663            None,
8664            Arc::new(crate::transport::NetworkFactory::tokio()),
8665            None, // M96: hash_pool
8666        )
8667        .await
8668        .unwrap();
8669
8670        assert!(
8671            !handle.have_piece(0).await.unwrap(),
8672            "piece 0 should not be downloaded initially"
8673        );
8674        assert!(
8675            !handle.have_piece(1).await.unwrap(),
8676            "piece 1 should not be downloaded initially"
8677        );
8678
8679        handle.shutdown().await.unwrap();
8680    }
8681
8682    // ---- Test: piece_availability empty with no peers ----
8683
8684    #[tokio::test]
8685    async fn piece_availability_empty_no_peers() {
8686        let data = vec![0xAB; 32768]; // 2 pieces
8687        let meta = make_test_torrent(&data, 16384);
8688        let storage = make_storage(&data, 16384);
8689        let config = test_config();
8690
8691        let (atx, amask) = test_alert_channel();
8692        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8693        let handle = TorrentHandle::from_torrent(
8694            meta,
8695            irontide_core::TorrentVersion::V1Only,
8696            None,
8697            dh,
8698            dm,
8699            config,
8700            None,
8701            None,
8702            None,
8703            None,
8704            crate::slot_tuner::SlotTuner::disabled(4),
8705            atx,
8706            amask,
8707            None,
8708            None,
8709            test_ban_manager(),
8710            test_ip_filter(),
8711            Arc::new(Vec::new()),
8712            None,
8713            None,
8714            Arc::new(crate::transport::NetworkFactory::tokio()),
8715            None, // M96: hash_pool
8716        )
8717        .await
8718        .unwrap();
8719
8720        let avail = handle.piece_availability().await.unwrap();
8721        assert_eq!(avail.len(), 2, "should have availability for 2 pieces");
8722        assert!(
8723            avail.iter().all(|&c| c == 0),
8724            "all availability counts should be 0 with no peers"
8725        );
8726
8727        handle.shutdown().await.unwrap();
8728    }
8729
8730    // ---- Test: file_progress zeros initially ----
8731
8732    #[tokio::test]
8733    async fn file_progress_zeros_initially() {
8734        let data = vec![0xAB; 32768]; // single-file, 2 pieces
8735        let meta = make_test_torrent(&data, 16384);
8736        let storage = make_storage(&data, 16384);
8737        let config = test_config();
8738
8739        let (atx, amask) = test_alert_channel();
8740        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8741        let handle = TorrentHandle::from_torrent(
8742            meta,
8743            irontide_core::TorrentVersion::V1Only,
8744            None,
8745            dh,
8746            dm,
8747            config,
8748            None,
8749            None,
8750            None,
8751            None,
8752            crate::slot_tuner::SlotTuner::disabled(4),
8753            atx,
8754            amask,
8755            None,
8756            None,
8757            test_ban_manager(),
8758            test_ip_filter(),
8759            Arc::new(Vec::new()),
8760            None,
8761            None,
8762            Arc::new(crate::transport::NetworkFactory::tokio()),
8763            None, // M96: hash_pool
8764        )
8765        .await
8766        .unwrap();
8767
8768        let progress = handle.file_progress().await.unwrap();
8769        assert_eq!(progress.len(), 1, "single-file torrent should have 1 entry");
8770        assert_eq!(progress[0], 0, "no bytes should be downloaded initially");
8771
8772        handle.shutdown().await.unwrap();
8773    }
8774
8775    // ---- Test: file_progress length matches file count (multi-file) ----
8776
8777    /// Build a multi-file TorrentMetaV1 from a total data blob and file lengths.
8778    fn make_test_torrent_multi(
8779        data: &[u8],
8780        piece_length: u64,
8781        file_lengths: &[u64],
8782    ) -> TorrentMetaV1 {
8783        use serde::Serialize;
8784
8785        let mut pieces = Vec::new();
8786        let mut offset = 0;
8787        while offset < data.len() {
8788            let end = (offset + piece_length as usize).min(data.len());
8789            let hash = irontide_core::sha1(&data[offset..end]);
8790            pieces.extend_from_slice(hash.as_bytes());
8791            offset = end;
8792        }
8793
8794        #[derive(Serialize)]
8795        struct FileE {
8796            length: u64,
8797            path: Vec<String>,
8798        }
8799
8800        #[derive(Serialize)]
8801        struct Info<'a> {
8802            name: &'a str,
8803            #[serde(rename = "piece length")]
8804            piece_length: u64,
8805            #[serde(with = "serde_bytes")]
8806            pieces: &'a [u8],
8807            files: Vec<FileE>,
8808        }
8809
8810        #[derive(Serialize)]
8811        struct Torrent<'a> {
8812            info: Info<'a>,
8813        }
8814
8815        let files: Vec<FileE> = file_lengths
8816            .iter()
8817            .enumerate()
8818            .map(|(i, &len)| FileE {
8819                length: len,
8820                path: vec![format!("file{i}.bin")],
8821            })
8822            .collect();
8823
8824        let t = Torrent {
8825            info: Info {
8826                name: "test_multi",
8827                piece_length,
8828                pieces: &pieces,
8829                files,
8830            },
8831        };
8832
8833        let bytes = irontide_bencode::to_bytes(&t).unwrap();
8834        torrent_from_bytes(&bytes).unwrap()
8835    }
8836
8837    #[tokio::test]
8838    async fn file_progress_length_matches_file_count() {
8839        // 3 files: 10000 + 20000 + 2768 = 32768 bytes total, 2 pieces of 16384
8840        let data = vec![0xCD; 32768];
8841        let file_lengths = [10000u64, 20000, 2768];
8842        let meta = make_test_torrent_multi(&data, 16384, &file_lengths);
8843        let storage = make_storage(&data, 16384);
8844        let config = test_config();
8845
8846        let (atx, amask) = test_alert_channel();
8847        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8848        let handle = TorrentHandle::from_torrent(
8849            meta,
8850            irontide_core::TorrentVersion::V1Only,
8851            None,
8852            dh,
8853            dm,
8854            config,
8855            None,
8856            None,
8857            None,
8858            None,
8859            crate::slot_tuner::SlotTuner::disabled(4),
8860            atx,
8861            amask,
8862            None,
8863            None,
8864            test_ban_manager(),
8865            test_ip_filter(),
8866            Arc::new(Vec::new()),
8867            None,
8868            None,
8869            Arc::new(crate::transport::NetworkFactory::tokio()),
8870            None, // M96: hash_pool
8871        )
8872        .await
8873        .unwrap();
8874
8875        let progress = handle.file_progress().await.unwrap();
8876        assert_eq!(
8877            progress.len(),
8878            3,
8879            "multi-file torrent should have 3 entries"
8880        );
8881        assert!(
8882            progress.iter().all(|&b| b == 0),
8883            "all progress should be 0 initially"
8884        );
8885
8886        handle.shutdown().await.unwrap();
8887    }
8888
8889    // ---- Test: is_valid returns true for active torrent ----
8890
8891    #[tokio::test]
8892    async fn is_valid_true_for_active() {
8893        let data = vec![0xAB; 32768];
8894        let meta = make_test_torrent(&data, 16384);
8895        let storage = make_storage(&data, 16384);
8896        let config = test_config();
8897
8898        let (atx, amask) = test_alert_channel();
8899        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8900        let handle = TorrentHandle::from_torrent(
8901            meta,
8902            irontide_core::TorrentVersion::V1Only,
8903            None,
8904            dh,
8905            dm,
8906            config,
8907            None,
8908            None,
8909            None,
8910            None,
8911            crate::slot_tuner::SlotTuner::disabled(4),
8912            atx,
8913            amask,
8914            None,
8915            None,
8916            test_ban_manager(),
8917            test_ip_filter(),
8918            Arc::new(Vec::new()),
8919            None,
8920            None,
8921            Arc::new(crate::transport::NetworkFactory::tokio()),
8922            None, // M96: hash_pool
8923        )
8924        .await
8925        .unwrap();
8926
8927        assert!(
8928            handle.is_valid(),
8929            "handle should be valid while torrent actor is alive"
8930        );
8931
8932        handle.shutdown().await.unwrap();
8933    }
8934
8935    // ---- Test: is_valid returns false after shutdown ----
8936
8937    #[tokio::test]
8938    async fn is_valid_false_after_remove() {
8939        let data = vec![0xAB; 32768];
8940        let meta = make_test_torrent(&data, 16384);
8941        let storage = make_storage(&data, 16384);
8942        let config = test_config();
8943
8944        let (atx, amask) = test_alert_channel();
8945        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8946        let handle = TorrentHandle::from_torrent(
8947            meta,
8948            irontide_core::TorrentVersion::V1Only,
8949            None,
8950            dh,
8951            dm,
8952            config,
8953            None,
8954            None,
8955            None,
8956            None,
8957            crate::slot_tuner::SlotTuner::disabled(4),
8958            atx,
8959            amask,
8960            None,
8961            None,
8962            test_ban_manager(),
8963            test_ip_filter(),
8964            Arc::new(Vec::new()),
8965            None,
8966            None,
8967            Arc::new(crate::transport::NetworkFactory::tokio()),
8968            None, // M96: hash_pool
8969        )
8970        .await
8971        .unwrap();
8972
8973        assert!(handle.is_valid());
8974
8975        // Shutdown the torrent (simulating removal)
8976        handle.shutdown().await.unwrap();
8977
8978        // Give the actor time to stop and close the channel
8979        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8980
8981        assert!(
8982            !handle.is_valid(),
8983            "handle should be invalid after shutdown"
8984        );
8985    }
8986
8987    // ---- Test: clear_error resets error state ----
8988
8989    #[tokio::test]
8990    async fn clear_error_resets() {
8991        let data = vec![0xAB; 32768];
8992        let meta = make_test_torrent(&data, 16384);
8993        let storage = make_storage(&data, 16384);
8994        let config = test_config();
8995
8996        let (atx, amask) = test_alert_channel();
8997        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
8998        let handle = TorrentHandle::from_torrent(
8999            meta,
9000            irontide_core::TorrentVersion::V1Only,
9001            None,
9002            dh,
9003            dm,
9004            config,
9005            None,
9006            None,
9007            None,
9008            None,
9009            crate::slot_tuner::SlotTuner::disabled(4),
9010            atx,
9011            amask,
9012            None,
9013            None,
9014            test_ban_manager(),
9015            test_ip_filter(),
9016            Arc::new(Vec::new()),
9017            None,
9018            None,
9019            Arc::new(crate::transport::NetworkFactory::tokio()),
9020            None, // M96: hash_pool
9021        )
9022        .await
9023        .unwrap();
9024
9025        // Initially no error
9026        let stats = handle.stats().await.unwrap();
9027        assert!(stats.error.is_empty());
9028        assert_eq!(stats.error_file, -1);
9029
9030        // Clear error (no-op when no error) should succeed without issue
9031        handle.clear_error().await.unwrap();
9032
9033        let stats = handle.stats().await.unwrap();
9034        assert!(stats.error.is_empty());
9035        assert_eq!(stats.error_file, -1);
9036
9037        handle.shutdown().await.unwrap();
9038    }
9039
9040    // ---- Test: flags round trip ----
9041
9042    #[tokio::test]
9043    async fn flags_round_trip() {
9044        let data = vec![0xAB; 32768];
9045        let meta = make_test_torrent(&data, 16384);
9046        let storage = make_storage(&data, 16384);
9047        let config = test_config();
9048
9049        let (atx, amask) = test_alert_channel();
9050        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9051        let handle = TorrentHandle::from_torrent(
9052            meta,
9053            irontide_core::TorrentVersion::V1Only,
9054            None,
9055            dh,
9056            dm,
9057            config,
9058            None,
9059            None,
9060            None,
9061            None,
9062            crate::slot_tuner::SlotTuner::disabled(4),
9063            atx,
9064            amask,
9065            None,
9066            None,
9067            test_ban_manager(),
9068            test_ip_filter(),
9069            Arc::new(Vec::new()),
9070            None,
9071            None,
9072            Arc::new(crate::transport::NetworkFactory::tokio()),
9073            None, // M96: hash_pool
9074        )
9075        .await
9076        .unwrap();
9077
9078        // Initial flags: torrent starts downloading (not paused), no sequential, no super seeding
9079        let initial = handle.flags().await.unwrap();
9080        assert!(!initial.contains(crate::types::TorrentFlags::PAUSED));
9081        assert!(!initial.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
9082        assert!(!initial.contains(crate::types::TorrentFlags::SUPER_SEEDING));
9083
9084        // Enable sequential download via set_flags
9085        handle
9086            .set_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
9087            .await
9088            .unwrap();
9089        let after_set = handle.flags().await.unwrap();
9090        assert!(after_set.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
9091
9092        // Disable it via unset_flags
9093        handle
9094            .unset_flags(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD)
9095            .await
9096            .unwrap();
9097        let after_unset = handle.flags().await.unwrap();
9098        assert!(!after_unset.contains(crate::types::TorrentFlags::SEQUENTIAL_DOWNLOAD));
9099
9100        // Verify sequential_download state via the dedicated query
9101        assert!(!handle.is_sequential_download().await.unwrap());
9102
9103        handle.shutdown().await.unwrap();
9104    }
9105
9106    // ---- Test: connect_peer does not error ----
9107
9108    #[tokio::test]
9109    async fn connect_peer_no_error() {
9110        let data = vec![0xAB; 32768];
9111        let meta = make_test_torrent(&data, 16384);
9112        let storage = make_storage(&data, 16384);
9113        let config = test_config();
9114
9115        let (atx, amask) = test_alert_channel();
9116        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
9117        let handle = TorrentHandle::from_torrent(
9118            meta,
9119            irontide_core::TorrentVersion::V1Only,
9120            None,
9121            dh,
9122            dm,
9123            config,
9124            None,
9125            None,
9126            None,
9127            None,
9128            crate::slot_tuner::SlotTuner::disabled(4),
9129            atx,
9130            amask,
9131            None,
9132            None,
9133            test_ban_manager(),
9134            test_ip_filter(),
9135            Arc::new(Vec::new()),
9136            None,
9137            None,
9138            Arc::new(crate::transport::NetworkFactory::tokio()),
9139            None, // M96: hash_pool
9140        )
9141        .await
9142        .unwrap();
9143
9144        // connect_peer should not error even though the peer doesn't exist
9145        // (the connection attempt will fail asynchronously, but the command itself succeeds)
9146        let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap();
9147        handle.connect_peer(addr).await.unwrap();
9148
9149        // Give the actor a moment to process
9150        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
9151
9152        handle.shutdown().await.unwrap();
9153    }
9154
9155    // ---- BEP 52 hash serving tests (M87) ----
9156
9157    /// Build a minimal TorrentMetaV2 with piece-layer hashes for testing.
9158    fn make_test_meta_v2(
9159        piece_hashes: &[irontide_core::Id32],
9160        file_root: irontide_core::Id32,
9161        piece_length: u64,
9162        file_length: u64,
9163    ) -> irontide_core::TorrentMetaV2 {
9164        use std::collections::BTreeMap;
9165
9166        // Concatenate piece hashes into raw bytes
9167        let mut layer_bytes = Vec::with_capacity(piece_hashes.len() * 32);
9168        for h in piece_hashes {
9169            layer_bytes.extend_from_slice(&h.0);
9170        }
9171
9172        let mut piece_layers = BTreeMap::new();
9173        piece_layers.insert(file_root, layer_bytes);
9174
9175        let file_tree = irontide_core::FileTreeNode::Directory({
9176            let mut children = BTreeMap::new();
9177            children.insert(
9178                "test.dat".to_string(),
9179                irontide_core::FileTreeNode::File(irontide_core::V2FileAttr {
9180                    length: file_length,
9181                    pieces_root: Some(file_root),
9182                }),
9183            );
9184            children
9185        });
9186
9187        irontide_core::TorrentMetaV2 {
9188            info_hashes: irontide_core::InfoHashes::v2_only(irontide_core::Id32::ZERO),
9189            info_bytes: None,
9190            announce: None,
9191            announce_list: None,
9192            comment: None,
9193            created_by: None,
9194            creation_date: None,
9195            info: irontide_core::InfoDictV2 {
9196                name: "test".to_string(),
9197                piece_length,
9198                meta_version: 2,
9199                file_tree,
9200                ssl_cert: None,
9201            },
9202            piece_layers,
9203            ssl_cert: None,
9204        }
9205    }
9206
9207    #[test]
9208    fn test_serve_hashes_v2_piece_layer() {
9209        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
9210        // => blocks_per_piece = 1, piece_layer_base = 0
9211        let hashes: Vec<irontide_core::Id32> = (0..4u8)
9212            .map(|i| {
9213                let mut h = [0u8; 32];
9214                h[0] = i;
9215                irontide_core::Id32(h)
9216            })
9217            .collect();
9218        let file_root = irontide_core::Id32([0xAA; 32]);
9219        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
9220        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
9221
9222        let request = irontide_core::HashRequest {
9223            file_root,
9224            base: 0, // piece layer when blocks_per_piece = 1
9225            index: 0,
9226            count: 4,
9227            proof_layers: 0,
9228        };
9229
9230        let result = serve_hashes(
9231            Some(&meta),
9232            irontide_core::TorrentVersion::V2Only,
9233            Some(&lengths),
9234            &request,
9235        );
9236        let served = result.expect("should serve hashes");
9237        assert_eq!(served.len(), 4);
9238        for (i, h) in served.iter().enumerate() {
9239            assert_eq!(h.0[0], i as u8);
9240        }
9241    }
9242
9243    #[test]
9244    fn test_serve_hashes_rejects_v1_only() {
9245        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
9246        let file_root = irontide_core::Id32([0xAA; 32]);
9247        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
9248        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
9249
9250        let request = irontide_core::HashRequest {
9251            file_root,
9252            base: 0,
9253            index: 0,
9254            count: 1,
9255            proof_layers: 0,
9256        };
9257
9258        let result = serve_hashes(
9259            Some(&meta),
9260            irontide_core::TorrentVersion::V1Only,
9261            Some(&lengths),
9262            &request,
9263        );
9264        assert!(result.is_none(), "V1Only should reject hash requests");
9265    }
9266
9267    #[test]
9268    fn test_serve_hashes_rejects_unknown_root() {
9269        let hashes: Vec<irontide_core::Id32> = vec![irontide_core::Id32([0xBB; 32])];
9270        let file_root = irontide_core::Id32([0xAA; 32]);
9271        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384);
9272        let lengths = Lengths::new(16384, 16384, DEFAULT_CHUNK_SIZE);
9273
9274        // Request a different file root that doesn't exist
9275        let unknown_root = irontide_core::Id32([0xFF; 32]);
9276        let request = irontide_core::HashRequest {
9277            file_root: unknown_root,
9278            base: 0,
9279            index: 0,
9280            count: 1,
9281            proof_layers: 0,
9282        };
9283
9284        let result = serve_hashes(
9285            Some(&meta),
9286            irontide_core::TorrentVersion::V2Only,
9287            Some(&lengths),
9288            &request,
9289        );
9290        assert!(result.is_none(), "unknown file_root should reject");
9291    }
9292
9293    #[test]
9294    fn test_serve_hashes_rejects_out_of_bounds() {
9295        // 2 piece hashes, piece_length = 16384, chunk_size = 16384
9296        let hashes: Vec<irontide_core::Id32> =
9297            (0..2u8).map(|i| irontide_core::Id32([i; 32])).collect();
9298        let file_root = irontide_core::Id32([0xAA; 32]);
9299        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 2);
9300        let lengths = Lengths::new(16384 * 2, 16384, DEFAULT_CHUNK_SIZE);
9301
9302        // Request starting at index 5, which is beyond the 2 available hashes
9303        let request = irontide_core::HashRequest {
9304            file_root,
9305            base: 0,
9306            index: 5,
9307            count: 1,
9308            proof_layers: 0,
9309        };
9310
9311        let result = serve_hashes(
9312            Some(&meta),
9313            irontide_core::TorrentVersion::V2Only,
9314            Some(&lengths),
9315            &request,
9316        );
9317        assert!(result.is_none(), "out-of-bounds index should reject");
9318    }
9319
9320    #[test]
9321    fn test_serve_hashes_includes_proofs() {
9322        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
9323        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
9324        let hashes: Vec<irontide_core::Id32> =
9325            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
9326        let file_root = irontide_core::Id32([0xAA; 32]);
9327        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
9328        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
9329
9330        // Request 1 hash with 1 proof layer
9331        let request = irontide_core::HashRequest {
9332            file_root,
9333            base: 0,
9334            index: 0,
9335            count: 1,
9336            proof_layers: 1,
9337        };
9338
9339        let result = serve_hashes(
9340            Some(&meta),
9341            irontide_core::TorrentVersion::V2Only,
9342            Some(&lengths),
9343            &request,
9344        );
9345        let served = result.expect("should serve hashes with proofs");
9346        // 1 requested hash + 1 proof hash (sibling of leaf 0) = 2 total
9347        assert_eq!(served.len(), 2, "should have 1 data hash + 1 proof hash");
9348        // First hash is the requested piece hash
9349        assert_eq!(served[0], hashes[0]);
9350        // Second hash is the sibling (proof) — which is hashes[1]
9351        assert_eq!(served[1], hashes[1]);
9352    }
9353
9354    #[test]
9355    fn test_serve_hashes_proof_with_batch() {
9356        // 4 piece hashes, piece_length = 16384, chunk_size = 16384
9357        // => blocks_per_piece = 1, tree has 4 leaves => depth 2
9358        //
9359        // Tree layout (1-indexed heap):
9360        //          [1] root
9361        //        /          \
9362        //     [2]            [3]
9363        //    /    \         /    \
9364        //  [4]h0  [5]h1  [6]h2  [7]h3
9365        //
9366        // Request count=2 at index=0 => subtree rooted at [2] (h0, h1).
9367        // subtree_depth = log2(2) = 1, so we skip 1 level of the proof path.
9368        // proof_path(0) = [h1, hash(h2,h3)] — h1 is internal to subtree,
9369        // hash(h2,h3) is the uncle above. We skip h1 and send hash(h2,h3).
9370        let hashes: Vec<irontide_core::Id32> =
9371            (0..4u8).map(|i| irontide_core::Id32([i; 32])).collect();
9372        let file_root = irontide_core::Id32([0xAA; 32]);
9373        let meta = make_test_meta_v2(&hashes, file_root, 16384, 16384 * 4);
9374        let lengths = Lengths::new(16384 * 4, 16384, DEFAULT_CHUNK_SIZE);
9375
9376        let request = irontide_core::HashRequest {
9377            file_root,
9378            base: 0,
9379            index: 0,
9380            count: 2,
9381            proof_layers: 1,
9382        };
9383
9384        let result = serve_hashes(
9385            Some(&meta),
9386            irontide_core::TorrentVersion::V2Only,
9387            Some(&lengths),
9388            &request,
9389        );
9390        let served = result.expect("should serve hashes with batch proof");
9391        // 2 base hashes + 1 uncle hash = 3 total
9392        assert_eq!(served.len(), 3, "should have 2 data hashes + 1 uncle hash");
9393        // First two are the requested piece hashes
9394        assert_eq!(served[0], hashes[0]);
9395        assert_eq!(served[1], hashes[1]);
9396        // Third is the uncle: sibling of the subtree root at [2],
9397        // which is the node at [3] = hash(h2, h3)
9398        let tree = irontide_core::MerkleTree::from_leaves(&hashes);
9399        let expected_uncle = tree.layer(1)[1]; // layer 1 has 2 nodes; index 1 is the right one
9400        assert_eq!(served[2], expected_uncle);
9401
9402        // Verify the proof is valid: reconstruct subtree root from base hashes,
9403        // then verify against the tree root using the uncle hash
9404        let sub_root = irontide_core::MerkleTree::root_from_hashes(&served[..2]);
9405        let uncle_hashes = &served[2..];
9406        let leaf_index = request.index as usize / 2; // 0 / 2 = 0
9407        assert!(
9408            irontide_core::MerkleTree::verify_proof(
9409                tree.root(),
9410                sub_root,
9411                leaf_index,
9412                uncle_hashes
9413            ),
9414            "subtree proof should verify against tree root"
9415        );
9416    }
9417
9418    #[test]
9419    fn is_i2p_synthetic_addr_detects_240_range() {
9420        assert!(is_i2p_synthetic_addr(&"240.0.0.1:1".parse().unwrap()));
9421        assert!(is_i2p_synthetic_addr(
9422            &"255.255.255.255:65535".parse().unwrap()
9423        ));
9424        assert!(!is_i2p_synthetic_addr(&"192.168.1.1:6881".parse().unwrap()));
9425        assert!(!is_i2p_synthetic_addr(&"[::1]:6881".parse().unwrap()));
9426    }
9427
9428    #[tokio::test]
9429    #[ignore = "requires local I2P router with SAM bridge on 127.0.0.1:7656"]
9430    async fn i2p_session_integration() {
9431        let session = crate::i2p::SamSession::create(
9432            "127.0.0.1",
9433            7656,
9434            "integration-test",
9435            crate::i2p::SamTunnelConfig::default(),
9436        )
9437        .await
9438        .expect("SAM session should connect");
9439        assert!(!session.destination().is_empty());
9440        assert!(session.destination().to_b32_address().ends_with(".b32.i2p"));
9441    }
9442
9443    #[test]
9444    fn v6_retry_delay_progression() {
9445        // Verify exponential backoff: 100, 200, 400, 800, 1600, 3200, 5000, 5000...
9446        let expected_ms = [100, 200, 400, 800, 1600, 3200, 5000, 5000, 5000, 5000, 5000];
9447        for (count, &expected) in expected_ms.iter().enumerate() {
9448            let delay_ms = {
9449                let base_ms: u64 = 100;
9450                let max_ms: u64 = 5000;
9451                base_ms
9452                    .saturating_mul(1u64.checked_shl(count as u32).unwrap_or(u64::MAX))
9453                    .min(max_ms)
9454            };
9455            assert_eq!(
9456                delay_ms, expected,
9457                "count={count}: expected {expected}ms, got {delay_ms}ms"
9458            );
9459        }
9460    }
9461
9462    // ---- M104: Per-peer backoff and max_in_flight formula tests ----
9463
9464    #[test]
9465    fn peer_backoff_exponential() {
9466        // Verify the M104 backoff formula: 200ms * 2^attempt, capped at 30s.
9467        // attempt starts at 1 (first failure increments 0 → 1).
9468        let expected_ms: Vec<u64> = vec![400, 800, 1600, 3200, 6400, 12800, 25600, 30000, 30000];
9469        for (i, &expected) in expected_ms.iter().enumerate() {
9470            let attempt = (i as u32) + 1; // attempt counts start at 1
9471            let delay_ms = 200u64.saturating_mul(1u64 << attempt.min(10)).min(30_000);
9472            assert_eq!(
9473                delay_ms, expected,
9474                "attempt={attempt}: expected {expected}ms, got {delay_ms}ms"
9475            );
9476        }
9477    }
9478
9479    #[test]
9480    fn peer_backoff_clears_on_data() {
9481        // Verify that backoff map operations work correctly:
9482        // insert on disconnect, remove on data received.
9483        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
9484        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
9485
9486        // No backoff initially
9487        assert!(backoff.get(&addr).is_none());
9488
9489        // First disconnect: attempt 1
9490        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
9491        let next = attempt.saturating_add(1);
9492        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
9493        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
9494        backoff.insert(addr, (earliest, next));
9495        assert_eq!(backoff.get(&addr).unwrap().1, 1);
9496
9497        // Second disconnect: attempt 2
9498        let attempt = backoff.get(&addr).map_or(0, |&(_, a)| a);
9499        let next = attempt.saturating_add(1);
9500        let delay_ms = 200u64.saturating_mul(1u64 << next.min(10)).min(30_000);
9501        let earliest = std::time::Instant::now() + Duration::from_millis(delay_ms);
9502        backoff.insert(addr, (earliest, next));
9503        assert_eq!(backoff.get(&addr).unwrap().1, 2);
9504
9505        // Data received: clear
9506        backoff.remove(&addr);
9507        assert!(backoff.get(&addr).is_none());
9508    }
9509
9510    #[test]
9511    fn backoff_prevents_hammering() {
9512        // Verify that a peer with a future backoff time would be skipped.
9513        let mut backoff: HashMap<SocketAddr, (std::time::Instant, u32)> = HashMap::new();
9514        let addr: SocketAddr = "1.2.3.4:6881".parse().unwrap();
9515
9516        // Set backoff 10 seconds in the future
9517        let future = std::time::Instant::now() + Duration::from_secs(10);
9518        backoff.insert(addr, (future, 3));
9519
9520        // Should be skipped (now < next_attempt)
9521        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
9522            assert!(std::time::Instant::now() < next_attempt);
9523        }
9524
9525        // Set backoff in the past — should NOT be skipped
9526        let past = std::time::Instant::now() - Duration::from_secs(1);
9527        backoff.insert(addr, (past, 3));
9528        if let Some(&(next_attempt, _)) = backoff.get(&addr) {
9529            assert!(std::time::Instant::now() >= next_attempt);
9530        }
9531    }
9532
9533    #[test]
9534    fn max_in_flight_formula_updated() {
9535        // M104: max(512, connected*4) clamped to pieces/2, floored at 512.
9536        let formula = |connected: usize, num_pieces: u32| -> usize {
9537            let calculated = 512usize.max(connected.saturating_mul(4));
9538            calculated.min(num_pieces as usize / 2).max(512)
9539        };
9540
9541        // Few peers: floor dominates
9542        assert_eq!(formula(10, 2000), 512);
9543
9544        // Many peers: connected * 4 takes over
9545        assert_eq!(formula(200, 2000), 800);
9546
9547        // Very many peers: clamped by pieces/2
9548        assert_eq!(formula(500, 2000), 1000); // 2000 clamped to 1000
9549
9550        // Tiny torrent: floor dominates even with many peers
9551        assert_eq!(formula(200, 100), 512); // 800 clamped to 50, floored to 512
9552
9553        // Exact boundary
9554        assert_eq!(formula(128, 10000), 512); // 128*4=512, max(512,512)=512
9555        assert_eq!(formula(129, 10000), 516); // 129*4=516, max(512,516)=516
9556
9557        // Zero peers
9558        assert_eq!(formula(0, 10000), 512);
9559
9560        // Zero pieces (edge case — would give pieces/2=0, floor=512)
9561        assert_eq!(formula(100, 0), 512);
9562    }
9563
9564    // -- BEP 55 holepunch initiation tests (M112) --
9565
9566    #[test]
9567    fn should_attempt_holepunch_reason_classification() {
9568        // NAT-related reasons → true
9569        assert!(should_attempt_holepunch("connection refused"));
9570        assert!(should_attempt_holepunch("Connection refused"));
9571        assert!(should_attempt_holepunch("timed out"));
9572        assert!(should_attempt_holepunch("Connection reset by peer"));
9573        assert!(should_attempt_holepunch("connection reset by peer"));
9574        // Re-entrancy guard: holepunch-originated failures → false
9575        assert!(!should_attempt_holepunch(
9576            "holepunch TCP connect failed: Connection refused"
9577        ));
9578        // Non-NAT reasons → false
9579        assert!(!should_attempt_holepunch("peer banned"));
9580        assert!(!should_attempt_holepunch("protocol error"));
9581        assert!(!should_attempt_holepunch(""));
9582    }
9583
9584    #[test]
9585    fn holepunch_initiation_on_connect_failure() {
9586        // "connection refused" is the canonical NAT failure reason
9587        assert!(should_attempt_holepunch("connection refused"));
9588    }
9589
9590    #[test]
9591    fn holepunch_cooldown_prevents_retry() {
9592        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
9593        let addr: SocketAddr = "127.0.0.1:6881".parse().expect("valid test addr");
9594        let now = Instant::now();
9595        cooldowns.insert(addr, now);
9596        // addr is in cooldowns, so should be skipped on subsequent attempt
9597        assert!(cooldowns.contains_key(&addr));
9598    }
9599
9600    #[test]
9601    fn holepunch_cooldown_overflow_skips() {
9602        let mut cooldowns: HashMap<SocketAddr, Instant> = HashMap::new();
9603        let now = Instant::now();
9604        for i in 0..256u16 {
9605            let addr: SocketAddr = format!("10.0.{}.{}:6881", i / 256, i % 256)
9606                .parse()
9607                .expect("valid test addr");
9608            cooldowns.insert(addr, now);
9609        }
9610        assert_eq!(cooldowns.len(), HOLEPUNCH_MAX_TRACKED);
9611        // New entry should be skipped when at capacity
9612    }
9613
9614    #[test]
9615    fn holepunch_skipped_when_disabled() {
9616        // should_attempt_holepunch only checks the reason string, not config.
9617        // Config check happens in disconnect_peer.
9618        assert!(should_attempt_holepunch("connection refused"));
9619        // This test documents that should_attempt_holepunch is reason-only.
9620    }
9621
9622    #[test]
9623    fn holepunch_not_triggered_on_ban() {
9624        assert!(!should_attempt_holepunch("peer banned"));
9625        assert!(!should_attempt_holepunch("banned for bad data"));
9626    }
9627
9628    // -- M116: CachedFileInfo tests --
9629
9630    /// Helper to build a minimal TorrentMetaV1 with multi-file entries.
9631    fn make_multi_file_meta(files: Vec<(u64, &str)>, piece_length: u64) -> TorrentMetaV1 {
9632        let total_length: u64 = files.iter().map(|(len, _)| *len).sum();
9633        let num_pieces = total_length.div_ceil(piece_length) as usize;
9634        let file_entries: Vec<irontide_core::FileEntry> = files
9635            .iter()
9636            .map(|(length, name)| irontide_core::FileEntry {
9637                length: *length,
9638                path: vec![name.to_string()],
9639                attr: None,
9640                mtime: None,
9641                symlink_path: None,
9642            })
9643            .collect();
9644        TorrentMetaV1 {
9645            info_hash: Id20([0u8; 20]),
9646            announce: None,
9647            announce_list: None,
9648            comment: None,
9649            created_by: None,
9650            creation_date: None,
9651            info: irontide_core::InfoDict {
9652                name: "test".to_string(),
9653                piece_length,
9654                pieces: vec![0u8; num_pieces * 20],
9655                length: None,
9656                files: Some(file_entries),
9657                private: None,
9658                source: None,
9659                ssl_cert: None,
9660                similar: Vec::new(),
9661                collections: Vec::new(),
9662            },
9663            url_list: Vec::new(),
9664            httpseeds: Vec::new(),
9665            info_bytes: None,
9666            ssl_cert: None,
9667        }
9668    }
9669
9670    #[test]
9671    fn cached_files_populated_on_registration() {
9672        // 3 files: 100, 200, 50 bytes; piece_length = 100
9673        // Total = 350 bytes, 4 pieces (0..3)
9674        // File 0: offset 0..100  -> pieces [0, 0]
9675        // File 1: offset 100..300 -> pieces [1, 2]
9676        // File 2: offset 300..350 -> pieces [3, 3]
9677        let meta = make_multi_file_meta(vec![(100, "a.txt"), (200, "b.txt"), (50, "c.txt")], 100);
9678        let lengths = Lengths::new(350, 100, 16384);
9679        let cached = build_cached_file_info(&meta, &lengths);
9680
9681        assert_eq!(cached.entries.len(), 3);
9682
9683        assert_eq!(cached.entries[0].index, 0);
9684        assert_eq!(cached.entries[0].length, 100);
9685        assert_eq!(cached.entries[0].first_piece, 0);
9686        assert_eq!(cached.entries[0].last_piece, 0);
9687
9688        assert_eq!(cached.entries[1].index, 1);
9689        assert_eq!(cached.entries[1].length, 200);
9690        assert_eq!(cached.entries[1].first_piece, 1);
9691        assert_eq!(cached.entries[1].last_piece, 2);
9692
9693        assert_eq!(cached.entries[2].index, 2);
9694        assert_eq!(cached.entries[2].length, 50);
9695        assert_eq!(cached.entries[2].first_piece, 3);
9696        assert_eq!(cached.entries[2].last_piece, 3);
9697    }
9698
9699    #[test]
9700    fn cached_files_single_file_torrent() {
9701        // Single-file torrent: 500 bytes, piece_length = 100
9702        // 5 pieces (0..4)
9703        let meta = TorrentMetaV1 {
9704            info_hash: Id20([0u8; 20]),
9705            announce: None,
9706            announce_list: None,
9707            comment: None,
9708            created_by: None,
9709            creation_date: None,
9710            info: irontide_core::InfoDict {
9711                name: "single.bin".to_string(),
9712                piece_length: 100,
9713                pieces: vec![0u8; 5 * 20],
9714                length: Some(500),
9715                files: None,
9716                private: None,
9717                source: None,
9718                ssl_cert: None,
9719                similar: Vec::new(),
9720                collections: Vec::new(),
9721            },
9722            url_list: Vec::new(),
9723            httpseeds: Vec::new(),
9724            info_bytes: None,
9725            ssl_cert: None,
9726        };
9727        let lengths = Lengths::new(500, 100, 16384);
9728        let cached = build_cached_file_info(&meta, &lengths);
9729
9730        assert_eq!(cached.entries.len(), 1);
9731        assert_eq!(cached.entries[0].index, 0);
9732        assert_eq!(cached.entries[0].length, 500);
9733        assert_eq!(cached.entries[0].first_piece, 0);
9734        assert_eq!(cached.entries[0].last_piece, 4);
9735    }
9736
9737    // ── M132: Time-based steal-queue population tests ──
9738    //
9739    // These tests verify the steal-populate logic that runs in run_steal_queue_maintenance().
9740    // They build AtomicPieceStates and StealCandidates directly and exercise the
9741    // same scan loop used by the real implementation.
9742
9743    use crate::piece_reservation::{AtomicPieceStates, PieceState, StealCandidates};
9744    use irontide_storage::Bitfield;
9745
9746    /// Helper: run the steal-populate scan (mirrors run_steal_queue_maintenance).
9747    ///
9748    /// Returns the number of pieces pushed into the steal queue.
9749    fn steal_populate_scan(states: &AtomicPieceStates, sc: &StealCandidates) -> u32 {
9750        let mut pushed = 0u32;
9751        let num = states.len();
9752        for piece in 0..num {
9753            let state = states.get(piece);
9754            if state == PieceState::Reserved {
9755                sc.push(piece);
9756                pushed = pushed.saturating_add(1);
9757            }
9758        }
9759        pushed
9760    }
9761
9762    fn all_wanted(n: u32) -> Bitfield {
9763        let mut bf = Bitfield::new(n);
9764        for i in 0..n {
9765            bf.set(i);
9766        }
9767        bf
9768    }
9769
9770    #[test]
9771    fn steal_populate_pushes_reserved_pieces() {
9772        let n = 10;
9773        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
9774        let sc = StealCandidates::new();
9775
9776        // Reserve pieces 2, 5, 7
9777        assert!(states.try_reserve(2));
9778        assert!(states.try_reserve(5));
9779        assert!(states.try_reserve(7));
9780
9781        let pushed = steal_populate_scan(&states, &sc);
9782        assert_eq!(pushed, 3, "should push exactly the 3 reserved pieces");
9783
9784        // Verify they're in the queue
9785        let mut popped = Vec::new();
9786        while let Some(p) = sc.pop() {
9787            popped.push(p);
9788        }
9789        popped.sort();
9790        assert_eq!(popped, vec![2, 5, 7]);
9791    }
9792
9793    #[test]
9794    fn steal_populate_skips_non_reserved_states() {
9795        let n = 8;
9796        let mut have = Bitfield::new(n);
9797        have.set(0); // piece 0 = Complete
9798        let mut wanted = all_wanted(n);
9799        wanted.clear(1); // piece 1 = Unwanted
9800
9801        let states = AtomicPieceStates::new(n, &have, &wanted);
9802        let sc = StealCandidates::new();
9803
9804        // Reserve piece 3, leave rest as Available/Complete/Unwanted
9805        assert!(states.try_reserve(3));
9806
9807        let pushed = steal_populate_scan(&states, &sc);
9808        assert_eq!(pushed, 1, "only piece 3 (Reserved) should be pushed");
9809
9810        assert_eq!(sc.pop(), Some(3));
9811        assert_eq!(sc.pop(), None);
9812    }
9813
9814    #[test]
9815    fn steal_populate_deduplicates() {
9816        let n = 4;
9817        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
9818        let sc = StealCandidates::new();
9819
9820        assert!(states.try_reserve(1));
9821        assert!(states.try_reserve(2));
9822
9823        // First scan pushes 2 pieces
9824        let pushed1 = steal_populate_scan(&states, &sc);
9825        assert_eq!(pushed1, 2);
9826
9827        // Second scan: StealCandidates.push() deduplicates, so the queue
9828        // should still contain exactly 2 entries, not 4.
9829        let pushed2 = steal_populate_scan(&states, &sc);
9830        assert_eq!(pushed2, 2, "scan still reports 2 reserved pieces");
9831
9832        let mut count = 0u32;
9833        while sc.pop().is_some() {
9834            count = count.saturating_add(1);
9835        }
9836        assert_eq!(count, 2, "dedup means only 2 entries despite 2 scans");
9837    }
9838
9839    #[test]
9840    fn steal_populate_skips_completed_pieces() {
9841        let n = 5;
9842        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
9843        let sc = StealCandidates::new();
9844
9845        // Reserve all 5 pieces
9846        for i in 0..n {
9847            assert!(states.try_reserve(i));
9848        }
9849
9850        // Complete pieces 1 and 3 before the scan
9851        states.mark_complete(1);
9852        states.mark_complete(3);
9853
9854        let pushed = steal_populate_scan(&states, &sc);
9855        assert_eq!(pushed, 3, "3 pieces still Reserved (0, 2, 4)");
9856
9857        let mut popped = Vec::new();
9858        while let Some(p) = sc.pop() {
9859            popped.push(p);
9860        }
9861        popped.sort();
9862        assert_eq!(popped, vec![0, 2, 4]);
9863    }
9864
9865    #[test]
9866    fn steal_populate_empty_when_no_reserved() {
9867        let n = 6;
9868        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
9869        let sc = StealCandidates::new();
9870
9871        // No pieces reserved — scan should push nothing
9872        let pushed = steal_populate_scan(&states, &sc);
9873        assert_eq!(pushed, 0);
9874        assert_eq!(sc.pop(), None);
9875    }
9876
9877    #[test]
9878    fn steal_populate_with_endgame_pieces() {
9879        // Endgame pieces (state = Endgame) should NOT be pushed — only Reserved.
9880        let n = 4;
9881        let states = AtomicPieceStates::new(n, &Bitfield::new(n), &all_wanted(n));
9882        let sc = StealCandidates::new();
9883
9884        assert!(states.try_reserve(0));
9885        assert!(states.try_reserve(1));
9886        states.transition_to_endgame(1);
9887
9888        let pushed = steal_populate_scan(&states, &sc);
9889        assert_eq!(
9890            pushed, 1,
9891            "only piece 0 (Reserved) should be pushed, not piece 1 (Endgame)"
9892        );
9893        assert_eq!(sc.pop(), Some(0));
9894        assert_eq!(sc.pop(), None);
9895    }
9896
9897    // -------------------------------------------------------------------
9898    // M133: DHT re-query tests
9899    // -------------------------------------------------------------------
9900
9901    /// Verify the DHT re-query guard scales with `max_peers` config.
9902    ///
9903    /// The guard threshold is `max_peers * 4`. With default `max_peers = 128`,
9904    /// this becomes 512 (close to the old hardcoded 500).
9905    #[test]
9906    fn dht_requery_guard_scales_with_max_peers() {
9907        // max_peers = 128 → threshold = 512
9908        assert_eq!(128_usize.saturating_mul(4), 512);
9909
9910        // max_peers = 200 → threshold = 800
9911        assert_eq!(200_usize.saturating_mul(4), 800);
9912
9913        // max_peers = 50 → threshold = 200
9914        assert_eq!(50_usize.saturating_mul(4), 200);
9915
9916        // Overflow protection: saturating_mul handles usize::MAX
9917        assert_eq!(usize::MAX.saturating_mul(4), usize::MAX);
9918    }
9919
9920    // ---- M147: Pre-resolved metadata tests ----
9921
9922    /// Build a raw bencoded info dict and its SHA1 hash (for magnet link testing).
9923    fn make_test_info_bytes() -> (Vec<u8>, Id20) {
9924        use serde::Serialize;
9925
9926        let data = vec![0xAB; 1024];
9927        let piece_hash = irontide_core::sha1(&data);
9928        let mut pieces = Vec::new();
9929        pieces.extend_from_slice(piece_hash.as_bytes());
9930
9931        #[derive(Serialize)]
9932        struct Info<'a> {
9933            length: u64,
9934            name: &'a str,
9935            #[serde(rename = "piece length")]
9936            piece_length: u64,
9937            #[serde(with = "serde_bytes")]
9938            pieces: &'a [u8],
9939        }
9940
9941        let info = Info {
9942            length: 1024,
9943            name: "test",
9944            piece_length: 16384,
9945            pieces: &pieces,
9946        };
9947
9948        let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
9949        let info_hash = irontide_core::sha1(&info_bytes);
9950        (info_bytes, info_hash)
9951    }
9952
9953    /// Create a magnet-based TorrentHandle for testing PreResolvedMetadata.
9954    async fn create_magnet_handle(info_hash: Id20) -> TorrentHandle {
9955        let magnet = Magnet {
9956            info_hashes: irontide_core::InfoHashes::v1_only(info_hash),
9957            display_name: Some("test".into()),
9958            trackers: vec![],
9959            peers: vec![],
9960            selected_files: None,
9961        };
9962        let config = test_config();
9963        let (atx, amask) = test_alert_channel();
9964        let (dm, _dj) = test_disk_manager();
9965        TorrentHandle::from_magnet(
9966            magnet,
9967            dm,
9968            config,
9969            None,
9970            None,
9971            None,
9972            None,
9973            crate::slot_tuner::SlotTuner::disabled(4),
9974            atx,
9975            amask,
9976            None,
9977            None,
9978            test_ban_manager(),
9979            test_ip_filter(),
9980            Arc::new(Vec::new()),
9981            None,
9982            None,
9983            Arc::new(crate::transport::NetworkFactory::tokio()),
9984            None,
9985        )
9986        .await
9987        .unwrap()
9988    }
9989
9990    #[tokio::test]
9991    async fn pre_resolved_metadata_applies_when_fetching() {
9992        let (info_bytes, info_hash) = make_test_info_bytes();
9993        let handle = create_magnet_handle(info_hash).await;
9994
9995        // Verify we start in FetchingMetadata state.
9996        let stats = handle.stats().await.unwrap();
9997        assert_eq!(stats.state, TorrentState::FetchingMetadata);
9998
9999        // Send pre-resolved metadata (with a fake peer for pre-seeding).
10000        let peer_addr: SocketAddr = "127.0.0.1:6881".parse().unwrap();
10001        handle.send_pre_resolved_metadata(info_bytes, vec![peer_addr]);
10002
10003        // Give the actor time to process the command.
10004        tokio::time::sleep(Duration::from_millis(200)).await;
10005
10006        // Verify transition to Downloading state.
10007        let stats = handle.stats().await.unwrap();
10008        assert_eq!(
10009            stats.state,
10010            TorrentState::Downloading,
10011            "should have transitioned to Downloading after pre-resolved metadata"
10012        );
10013        assert!(
10014            stats.pieces_total > 0,
10015            "should know piece count after metadata resolution"
10016        );
10017
10018        handle.shutdown().await.unwrap();
10019    }
10020
10021    #[tokio::test]
10022    async fn pre_resolved_metadata_ignored_after_resolution() {
10023        // Create a .torrent-based handle (already in Downloading state).
10024        let data = vec![0xAB; 32768];
10025        let meta = make_test_torrent(&data, 16384);
10026        let info_hash = meta.info_hash;
10027        let storage = make_storage(&data, 16384);
10028        let config = test_config();
10029
10030        let (atx, amask) = test_alert_channel();
10031        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
10032        let handle = TorrentHandle::from_torrent(
10033            meta,
10034            irontide_core::TorrentVersion::V1Only,
10035            None,
10036            dh,
10037            dm,
10038            config,
10039            None,
10040            None,
10041            None,
10042            None,
10043            crate::slot_tuner::SlotTuner::disabled(4),
10044            atx,
10045            amask,
10046            None,
10047            None,
10048            test_ban_manager(),
10049            test_ip_filter(),
10050            Arc::new(Vec::new()),
10051            None,
10052            None,
10053            Arc::new(crate::transport::NetworkFactory::tokio()),
10054            None,
10055        )
10056        .await
10057        .unwrap();
10058
10059        let stats_before = handle.stats().await.unwrap();
10060        assert_eq!(stats_before.state, TorrentState::Downloading);
10061
10062        // Send pre-resolved metadata — should be silently ignored since
10063        // the actor is already past FetchingMetadata.
10064        let (info_bytes, _) = make_test_info_bytes();
10065        handle.send_pre_resolved_metadata(info_bytes, vec![]);
10066
10067        // Give the actor time to process (or ignore) the command.
10068        tokio::time::sleep(Duration::from_millis(100)).await;
10069
10070        // Verify state hasn't changed and no crash occurred.
10071        let stats_after = handle.stats().await.unwrap();
10072        assert_eq!(stats_after.state, TorrentState::Downloading);
10073        assert_eq!(stats_after.pieces_total, stats_before.pieces_total);
10074
10075        handle.shutdown().await.unwrap();
10076    }
10077
10078    #[tokio::test]
10079    async fn pre_resolved_metadata_with_invalid_hash_stays_fetching() {
10080        // Build info bytes with a WRONG info_hash — the SHA1 won't match
10081        // the magnet link's info_hash, so try_assemble_metadata should
10082        // fail verification and the actor should stay in FetchingMetadata.
10083        let (info_bytes, _correct_hash) = make_test_info_bytes();
10084
10085        // Use a different (wrong) info_hash for the magnet.
10086        let wrong_hash = Id20::from_hex("0000000000000000000000000000000000000001").unwrap();
10087        let handle = create_magnet_handle(wrong_hash).await;
10088
10089        let stats = handle.stats().await.unwrap();
10090        assert_eq!(stats.state, TorrentState::FetchingMetadata);
10091
10092        // Send metadata with mismatched hash — should fail verification.
10093        handle.send_pre_resolved_metadata(info_bytes, vec![]);
10094
10095        tokio::time::sleep(Duration::from_millis(200)).await;
10096
10097        // Actor should remain in FetchingMetadata (verification failed).
10098        let stats = handle.stats().await.unwrap();
10099        assert_eq!(
10100            stats.state,
10101            TorrentState::FetchingMetadata,
10102            "should stay in FetchingMetadata when info_hash doesn't match"
10103        );
10104
10105        handle.shutdown().await.unwrap();
10106    }
10107
10108    // ── M149: Dynamic pipeline depth formula tests ──
10109
10110    /// Compute the clamped pipeline depth from the formula used in `update_peer_rates`.
10111    fn compute_pipeline_depth(
10112        download_rate: u64,
10113        target_buffer_secs: f64,
10114        min_depth: u32,
10115        max_depth: u32,
10116    ) -> u32 {
10117        let target = ((download_rate as f64 / 16384.0) * target_buffer_secs) as u32;
10118        target.clamp(min_depth, max_depth)
10119    }
10120
10121    #[test]
10122    fn m149_pipeline_depth_fast_peer_clamped_to_max() {
10123        // Fast peer: 5.4 MB/s → (5_400_000 / 16384) * 2.0 = 659.2 → clamped to 512
10124        let depth = compute_pipeline_depth(5_400_000, 2.0, 16, 512);
10125        assert_eq!(depth, 512);
10126    }
10127
10128    #[test]
10129    fn m149_pipeline_depth_slow_peer_clamped_to_min() {
10130        // Slow peer: 0.1 MB/s → (100_000 / 16384) * 2.0 = 12.2 → clamped to 16
10131        let depth = compute_pipeline_depth(100_000, 2.0, 16, 512);
10132        assert_eq!(depth, 16);
10133    }
10134
10135    #[test]
10136    fn m149_pipeline_depth_medium_peer() {
10137        // Medium peer: 0.5 MB/s → (500_000 / 16384) * 2.0 = 61.0
10138        let depth = compute_pipeline_depth(500_000, 2.0, 16, 512);
10139        assert_eq!(depth, 61);
10140    }
10141
10142    #[test]
10143    fn m149_pipeline_depth_zero_rate() {
10144        // Zero throughput peer → 0 → clamped to min (16)
10145        let depth = compute_pipeline_depth(0, 2.0, 16, 512);
10146        assert_eq!(depth, 16);
10147    }
10148
10149    #[test]
10150    fn m149_pipeline_depth_initial_value() {
10151        // Initial target_depth should be INITIAL_QUEUE_DEPTH (128)
10152        use crate::peer_shared::INITIAL_QUEUE_DEPTH;
10153        assert_eq!(INITIAL_QUEUE_DEPTH, 128);
10154    }
10155
10156    // ---- M159: seed mode scheduling-suppression integration test ----
10157
10158    /// End-to-end test that seed mode actually suppresses new block request
10159    /// dispatch at the wire level.
10160    ///
10161    /// 1. Spin up a 2-piece torrent with no downloaded data.
10162    /// 2. Connect a mock seeder that advertises both pieces.
10163    /// 3. Wait for the actor to send at least one `Request` (normal dispatch).
10164    /// 4. Flip `set_seed_mode(true)`.
10165    /// 5. Observe that a `Cancel` is sent for the pending request, and that
10166    ///    no additional `Request` messages arrive within 500 ms.
10167    /// 6. Confirm the stats snapshot reflects `user_seed_mode == true`.
10168    #[tokio::test]
10169    async fn m159_seed_mode_suppresses_new_requests_on_wire() {
10170        let data = vec![0xAB; 32768]; // 32 KiB
10171        let meta = make_test_torrent(&data, 16384); // 2 pieces
10172        let info_hash = meta.info_hash;
10173        // Leecher has empty storage — wants both pieces.
10174        let storage = make_storage(&[0u8; 32768], 16384);
10175
10176        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
10177        let listen_addr = listener.local_addr().unwrap();
10178        let config = TorrentConfig {
10179            listen_port: listen_addr.port(),
10180            ..test_config()
10181        };
10182        drop(listener);
10183
10184        let (atx, amask) = test_alert_channel();
10185        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
10186        let handle = TorrentHandle::from_torrent(
10187            meta,
10188            irontide_core::TorrentVersion::V1Only,
10189            None,
10190            dh,
10191            dm,
10192            config,
10193            None,
10194            None,
10195            None,
10196            None,
10197            crate::slot_tuner::SlotTuner::disabled(4),
10198            atx,
10199            amask,
10200            None,
10201            None,
10202            test_ban_manager(),
10203            test_ip_filter(),
10204            Arc::new(Vec::new()),
10205            None,
10206            None,
10207            Arc::new(crate::transport::NetworkFactory::tokio()),
10208            None,
10209        )
10210        .await
10211        .unwrap();
10212
10213        tokio::time::sleep(Duration::from_millis(50)).await;
10214
10215        // Connect a mock seeder to the actor's listener.
10216        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
10217        let (reader, writer) = tokio::io::split(stream);
10218        let mut writer = writer;
10219        let mut reader = reader;
10220
10221        let hs = Handshake::new(
10222            info_hash,
10223            Id20::from_hex("dddddddddddddddddddddddddddddddddddddddd").unwrap(),
10224        );
10225        writer.write_all(&hs.to_bytes()).await.unwrap();
10226        writer.flush().await.unwrap();
10227        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
10228        reader.read_exact(&mut hs_buf).await.unwrap();
10229
10230        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
10231        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
10232
10233        // Drain the actor's ext handshake, then send ours.
10234        let _actor_ext_hs = framed_read.next().await;
10235        let ext_hs = ExtHandshake::new();
10236        let ext_payload = ext_hs.to_bytes().unwrap();
10237        framed_write
10238            .send(Message::Extended {
10239                ext_id: 0,
10240                payload: ext_payload,
10241            })
10242            .await
10243            .unwrap();
10244
10245        // Announce that we (the mock seeder) have both pieces.
10246        let mut bf = Bitfield::new(2);
10247        bf.set(0);
10248        bf.set(1);
10249        framed_write
10250            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
10251            .await
10252            .unwrap();
10253        framed_write.send(Message::Unchoke).await.unwrap();
10254
10255        // Wait for the actor to send its first Request (and any adjacent ones
10256        // inside one select tick). This confirms the normal dispatch path is
10257        // engaged before we flip into seed mode.
10258        let mut initial_request_seen = false;
10259        let wait_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
10260        loop {
10261            let remaining = wait_deadline.saturating_duration_since(tokio::time::Instant::now());
10262            if remaining.is_zero() {
10263                break;
10264            }
10265            match tokio::time::timeout(remaining, framed_read.next()).await {
10266                Ok(Some(Ok(Message::Request { .. }))) => {
10267                    initial_request_seen = true;
10268                    break;
10269                }
10270                Ok(Some(Ok(_))) => continue,
10271                _ => break,
10272            }
10273        }
10274        assert!(
10275            initial_request_seen,
10276            "actor should have sent a Request before seed mode toggle"
10277        );
10278
10279        // Flip user seed mode on. From this point forward the actor must not
10280        // dispatch any new Request messages.
10281        handle.set_seed_mode(true).await.unwrap();
10282
10283        // There's an inherent race between the actor processing the toggle
10284        // and the per-peer requester loop receiving its `DispatchCommand::Stop`
10285        // — a block may already be in the writer's queue when we flip. Drain
10286        // for a brief grace window, then verify the dispatch has fully halted
10287        // for a second longer window: if scheduling is truly suppressed, no
10288        // Request messages will arrive during the steady-state window.
10289        let grace_deadline = tokio::time::Instant::now() + Duration::from_millis(200);
10290        let mut cancel_seen = false;
10291        let mut grace_requests = 0u32;
10292        loop {
10293            let remaining = grace_deadline.saturating_duration_since(tokio::time::Instant::now());
10294            if remaining.is_zero() {
10295                break;
10296            }
10297            match tokio::time::timeout(remaining, framed_read.next()).await {
10298                Ok(Some(Ok(Message::Request { .. }))) => {
10299                    grace_requests += 1;
10300                }
10301                Ok(Some(Ok(Message::Cancel { .. }))) => {
10302                    cancel_seen = true;
10303                }
10304                Ok(Some(Ok(_))) => continue,
10305                Ok(None) => break,
10306                Ok(Some(Err(_))) => break,
10307                Err(_elapsed) => break,
10308            }
10309        }
10310        let _ = (cancel_seen, grace_requests);
10311
10312        // Steady-state window: if the dispatch path is really gated, zero
10313        // new Request messages must arrive for the next 500 ms.
10314        let steady_deadline = tokio::time::Instant::now() + Duration::from_millis(500);
10315        let mut steady_requests = 0u32;
10316        loop {
10317            let remaining = steady_deadline.saturating_duration_since(tokio::time::Instant::now());
10318            if remaining.is_zero() {
10319                break;
10320            }
10321            match tokio::time::timeout(remaining, framed_read.next()).await {
10322                Ok(Some(Ok(Message::Request { .. }))) => {
10323                    steady_requests += 1;
10324                }
10325                Ok(Some(Ok(_))) => continue,
10326                Ok(None) => break,
10327                Ok(Some(Err(_))) => break,
10328                Err(_elapsed) => break,
10329            }
10330        }
10331
10332        assert_eq!(
10333            steady_requests, 0,
10334            "after the Stop propagation grace window, no new Request messages \
10335             must appear during steady-state while user_seed_mode is active"
10336        );
10337
10338        // Stats should reflect the flag.
10339        let stats = handle.stats().await.unwrap();
10340        assert!(
10341            stats.user_seed_mode,
10342            "stats.user_seed_mode should be true after set_seed_mode(true)"
10343        );
10344
10345        handle.shutdown().await.unwrap();
10346    }
10347
10348    // ---- M159 Task 1: Wire-level test — uploads continue in seed mode ----
10349    //
10350    // The point of user seed mode is to stop *downloading* (suppress new
10351    // block requests we issue to peers) while still *uploading* (honouring
10352    // incoming `Request` messages from peers who want pieces we have).
10353    // The companion test `m159_seed_mode_suppresses_new_requests_on_wire`
10354    // covers the download-suppression half; this one closes the loop by
10355    // asserting that the upload path survives a seed-mode toggle.
10356    //
10357    // Test shape:
10358    //   1. Pre-seed storage with two verified pieces (actor starts in
10359    //      `Seeding` state because `make_seeded_storage` writes the full
10360    //      dataset before the actor runs initial verification).
10361    //   2. Flip `user_seed_mode` on via `set_seed_mode(true)`. This is the
10362    //      load-bearing step — uploads must still work *after* seed mode
10363    //      is enabled.
10364    //   3. Connect a fake leecher via a real `TcpListener`, complete the
10365    //      BT + extended handshake.
10366    //   4. Announce an empty bitfield and send `Interested`. The choker
10367    //      still runs in seed mode, so the actor must respond with
10368    //      `Unchoke` (seed-mode choking algorithms unchoke interested
10369    //      peers based on upload throughput — a brand-new peer that just
10370    //      sent Interested is a valid candidate).
10371    //   5. Send `Request { index: 0, begin: 0, length: 16384 }` and assert
10372    //      a matching `Piece` message arrives on the wire within 2s, with
10373    //      a payload of the correct length and filled with the pre-seeded
10374    //      byte pattern.
10375    #[tokio::test]
10376    async fn m159_seed_mode_uploads_continue_on_wire() {
10377        const FILL_BYTE: u8 = 0x5A;
10378        const PIECE_LENGTH: u64 = 16384;
10379        const TOTAL_LEN: usize = 32768; // 2 pieces
10380
10381        let data = vec![FILL_BYTE; TOTAL_LEN];
10382        let meta = make_test_torrent(&data, PIECE_LENGTH);
10383        let info_hash = meta.info_hash;
10384        // Pre-seeded storage — actor transitions to Seeding after verify.
10385        let storage = make_seeded_storage(&data, PIECE_LENGTH);
10386
10387        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
10388        let listen_addr = listener.local_addr().unwrap();
10389        let config = TorrentConfig {
10390            listen_port: listen_addr.port(),
10391            ..test_config()
10392        };
10393        drop(listener);
10394
10395        let (atx, amask) = test_alert_channel();
10396        let (dh, dm, _dj) = test_register_disk(info_hash, storage).await;
10397        let handle = TorrentHandle::from_torrent(
10398            meta,
10399            irontide_core::TorrentVersion::V1Only,
10400            None,
10401            dh,
10402            dm,
10403            config,
10404            None,
10405            None,
10406            None,
10407            None,
10408            crate::slot_tuner::SlotTuner::disabled(4),
10409            atx,
10410            amask,
10411            None,
10412            None,
10413            test_ban_manager(),
10414            test_ip_filter(),
10415            Arc::new(Vec::new()),
10416            None,
10417            None,
10418            Arc::new(crate::transport::NetworkFactory::tokio()),
10419            None,
10420        )
10421        .await
10422        .unwrap();
10423
10424        // Wait for initial verification to complete so the actor is really
10425        // in Seeding state before we flip seed mode. Poll stats up to 3s.
10426        let seeding_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
10427        loop {
10428            tokio::time::sleep(Duration::from_millis(50)).await;
10429            let stats = handle.stats().await.unwrap();
10430            if stats.state == TorrentState::Seeding && stats.pieces_have == 2 {
10431                break;
10432            }
10433            if tokio::time::Instant::now() > seeding_deadline {
10434                let stats = handle.stats().await.unwrap();
10435                panic!(
10436                    "actor did not reach Seeding state within 3s: state={:?}, have={}/{}",
10437                    stats.state, stats.pieces_have, stats.pieces_total
10438                );
10439            }
10440        }
10441
10442        // Flip user seed mode on. The upload path must continue to serve
10443        // incoming Request messages from this point forward.
10444        handle.set_seed_mode(true).await.unwrap();
10445        let stats = handle.stats().await.unwrap();
10446        assert!(
10447            stats.user_seed_mode,
10448            "stats.user_seed_mode should be true after set_seed_mode(true)"
10449        );
10450
10451        // Connect a mock leecher to the actor's listener.
10452        let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap();
10453        let (reader, writer) = tokio::io::split(stream);
10454        let mut writer = writer;
10455        let mut reader = reader;
10456
10457        let hs = Handshake::new(
10458            info_hash,
10459            Id20::from_hex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").unwrap(),
10460        );
10461        writer.write_all(&hs.to_bytes()).await.unwrap();
10462        writer.flush().await.unwrap();
10463        let mut hs_buf = [0u8; HANDSHAKE_SIZE];
10464        reader.read_exact(&mut hs_buf).await.unwrap();
10465
10466        let mut framed_read = FramedRead::new(reader, MessageCodec::new());
10467        let mut framed_write = FramedWrite::new(writer, MessageCodec::new());
10468
10469        // Drain the actor's ext handshake, then send ours.
10470        let _actor_ext_hs = framed_read.next().await;
10471        let ext_hs = ExtHandshake::new();
10472        let ext_payload = ext_hs.to_bytes().unwrap();
10473        framed_write
10474            .send(Message::Extended {
10475                ext_id: 0,
10476                payload: ext_payload,
10477            })
10478            .await
10479            .unwrap();
10480
10481        // Tell the actor we (the mock leecher) have nothing.
10482        let bf = Bitfield::new(2);
10483        framed_write
10484            .send(Message::Bitfield(Bytes::copy_from_slice(bf.as_bytes())))
10485            .await
10486            .unwrap();
10487        framed_write.send(Message::Interested).await.unwrap();
10488
10489        // Wait for Unchoke from the actor. The actor may also send its own
10490        // Bitfield/Have/Extended/Choke/etc.; we drain non-Unchoke messages
10491        // until we see it (or time out).
10492        let unchoke_deadline = tokio::time::Instant::now() + Duration::from_secs(3);
10493        let mut saw_unchoke = false;
10494        loop {
10495            let remaining = unchoke_deadline.saturating_duration_since(tokio::time::Instant::now());
10496            if remaining.is_zero() {
10497                break;
10498            }
10499            match tokio::time::timeout(remaining, framed_read.next()).await {
10500                Ok(Some(Ok(Message::Unchoke))) => {
10501                    saw_unchoke = true;
10502                    break;
10503                }
10504                Ok(Some(Ok(_))) => continue,
10505                Ok(None) | Ok(Some(Err(_))) => break,
10506                Err(_elapsed) => break,
10507            }
10508        }
10509        assert!(
10510            saw_unchoke,
10511            "actor should have unchoked the leecher while user_seed_mode is active"
10512        );
10513
10514        // Request piece 0, full 16 KiB block. The actor is seeding with
10515        // seed mode on — it must still serve this upload.
10516        framed_write
10517            .send(Message::Request {
10518                index: 0,
10519                begin: 0,
10520                length: PIECE_LENGTH as u32,
10521            })
10522            .await
10523            .unwrap();
10524
10525        // Expect a Piece message to arrive on the wire with matching
10526        // index/begin and the correct payload. Drain any other messages
10527        // (Have, Bitfield updates, Choke refreshes, etc.) that may arrive
10528        // first.
10529        let piece_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
10530        let mut got_piece = false;
10531        loop {
10532            let remaining = piece_deadline.saturating_duration_since(tokio::time::Instant::now());
10533            if remaining.is_zero() {
10534                break;
10535            }
10536            match tokio::time::timeout(remaining, framed_read.next()).await {
10537                Ok(Some(Ok(Message::Piece {
10538                    index,
10539                    begin,
10540                    data_0,
10541                    data_1,
10542                }))) => {
10543                    assert_eq!(index, 0, "Piece index should match request");
10544                    assert_eq!(begin, 0, "Piece begin should match request");
10545                    let mut payload: Vec<u8> =
10546                        Vec::with_capacity(data_0.len().saturating_add(data_1.len()));
10547                    payload.extend_from_slice(&data_0);
10548                    payload.extend_from_slice(&data_1);
10549                    assert_eq!(
10550                        payload.len(),
10551                        PIECE_LENGTH as usize,
10552                        "Piece payload length should match requested length"
10553                    );
10554                    assert!(
10555                        payload.iter().all(|&b| b == FILL_BYTE),
10556                        "Piece payload should contain the pre-seeded fill byte"
10557                    );
10558                    got_piece = true;
10559                    break;
10560                }
10561                Ok(Some(Ok(_))) => continue,
10562                Ok(None) | Ok(Some(Err(_))) => break,
10563                Err(_elapsed) => break,
10564            }
10565        }
10566        assert!(
10567            got_piece,
10568            "actor should have served a Piece in response to Request while user_seed_mode is active"
10569        );
10570
10571        // Stats should still reflect the seed-mode flag and accumulated
10572        // upload bytes for the one block we served.
10573        let stats = handle.stats().await.unwrap();
10574        assert!(
10575            stats.user_seed_mode,
10576            "stats.user_seed_mode should remain true after serving an upload"
10577        );
10578        assert!(
10579            stats.uploaded >= u64::from(PIECE_LENGTH as u32),
10580            "stats.uploaded should reflect the served block, got {}",
10581            stats.uploaded
10582        );
10583
10584        handle.shutdown().await.unwrap();
10585    }
10586
10587    // ---- M161: info dict, v2 hash, and timestamp tests ----
10588
10589    #[tokio::test]
10590    async fn info_field_populated_for_torrent() {
10591        let data = vec![0xAB; 32768];
10592        let meta = make_test_torrent(&data, 16384);
10593        let storage = make_storage(&data, 16384);
10594        let config = test_config();
10595
10596        let (atx, amask) = test_alert_channel();
10597        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10598        let handle = TorrentHandle::from_torrent(
10599            meta,
10600            irontide_core::TorrentVersion::V1Only,
10601            None,
10602            dh,
10603            dm,
10604            config,
10605            None,
10606            None,
10607            None,
10608            None,
10609            crate::slot_tuner::SlotTuner::disabled(4),
10610            atx,
10611            amask,
10612            None,
10613            None,
10614            test_ban_manager(),
10615            test_ip_filter(),
10616            Arc::new(Vec::new()),
10617            None,
10618            None,
10619            Arc::new(crate::transport::NetworkFactory::tokio()),
10620            None,
10621        )
10622        .await
10623        .unwrap();
10624
10625        tokio::time::sleep(Duration::from_millis(50)).await;
10626
10627        let rd = handle.save_resume_data().await.unwrap();
10628
10629        // info field must be populated when metadata is available
10630        assert!(rd.info.is_some(), "rd.info should be Some for .torrent");
10631
10632        // The embedded bytes must deserialize back to a valid InfoDict
10633        let info_bytes = rd.info.as_ref().unwrap();
10634        let info: irontide_core::InfoDict =
10635            irontide_bencode::from_bytes(info_bytes).expect("info bytes should deserialize");
10636        assert_eq!(info.name, "test");
10637        assert_eq!(info.piece_length, 16384);
10638
10639        handle.shutdown().await.unwrap();
10640    }
10641
10642    #[tokio::test]
10643    async fn info_hash2_none_for_v1_only() {
10644        let data = vec![0xCD; 16384];
10645        let meta = make_test_torrent(&data, 16384);
10646        let storage = make_storage(&data, 16384);
10647        let config = test_config();
10648
10649        let (atx, amask) = test_alert_channel();
10650        let (dh, dm, _dj) = test_register_disk(meta.info_hash, storage).await;
10651        let handle = TorrentHandle::from_torrent(
10652            meta,
10653            irontide_core::TorrentVersion::V1Only,
10654            None,
10655            dh,
10656            dm,
10657            config,
10658            None,
10659            None,
10660            None,
10661            None,
10662            crate::slot_tuner::SlotTuner::disabled(4),
10663            atx,
10664            amask,
10665            None,
10666            None,
10667            test_ban_manager(),
10668            test_ip_filter(),
10669            Arc::new(Vec::new()),
10670            None,
10671            None,
10672            Arc::new(crate::transport::NetworkFactory::tokio()),
10673            None,
10674        )
10675        .await
10676        .unwrap();
10677
10678        tokio::time::sleep(Duration::from_millis(50)).await;
10679
10680        let rd = handle.save_resume_data().await.unwrap();
10681
10682        // v1-only torrent must not have a v2 hash
10683        assert!(
10684            rd.info_hash2.is_none(),
10685            "v1-only torrent should have info_hash2 = None"
10686        );
10687
10688        // Timestamps should be populated
10689        assert!(
10690            rd.added_time > 0,
10691            "added_time should be a positive POSIX timestamp"
10692        );
10693
10694        handle.shutdown().await.unwrap();
10695    }
10696
10697    #[tokio::test]
10698    async fn info_none_for_unresolved_magnet() {
10699        let magnet = Magnet {
10700            info_hashes: irontide_core::InfoHashes::v1_only(
10701                Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
10702            ),
10703            display_name: Some("magnet-test".into()),
10704            trackers: vec![],
10705            peers: vec![],
10706            selected_files: None,
10707        };
10708        let config = test_config();
10709
10710        let (atx, amask) = test_alert_channel();
10711        let (dm, _dj) = test_disk_manager();
10712        let handle = TorrentHandle::from_magnet(
10713            magnet,
10714            dm,
10715            config,
10716            None,
10717            None,
10718            None,
10719            None,
10720            crate::slot_tuner::SlotTuner::disabled(4),
10721            atx,
10722            amask,
10723            None,
10724            None,
10725            test_ban_manager(),
10726            test_ip_filter(),
10727            Arc::new(Vec::new()),
10728            None,
10729            None,
10730            Arc::new(crate::transport::NetworkFactory::tokio()),
10731            None,
10732        )
10733        .await
10734        .unwrap();
10735
10736        tokio::time::sleep(Duration::from_millis(50)).await;
10737
10738        let rd = handle.save_resume_data().await.unwrap();
10739
10740        // Unresolved magnet has no metadata, so info must be None
10741        assert!(
10742            rd.info.is_none(),
10743            "unresolved magnet should have info = None"
10744        );
10745
10746        // added_time should still be set even for magnets
10747        assert!(
10748            rd.added_time > 0,
10749            "added_time should be set for magnet links"
10750        );
10751
10752        handle.shutdown().await.unwrap();
10753    }
10754}